diff --git a/.ai/rules/feature-completeness.md b/.ai/rules/feature-completeness.md
new file mode 100644
index 0000000..b29d25e
--- /dev/null
+++ b/.ai/rules/feature-completeness.md
@@ -0,0 +1,80 @@
+---
+applyTo: "**/*"
+---
+
+# Feature Completeness
+
+Build success proves compilation. It proves nothing about behaviour. These standards exist because code that compiles cleanly can still be silently broken — callbacks wired but never triggered, UI elements that advertise features that do not exist, actions that return without doing anything or telling anyone why.
+
+## Build ≠ Behaviour
+
+> **"Build succeeded" is never a signal that work is complete.**
+
+Compilation verifies types and syntax. It does not verify that a button click actually fires, that a registered handler is ever called, that a feature works as documented, or that a user-facing interaction produces the expected result.
+
+When work cannot be verified at runtime — because there is no test harness, the environment is unavailable, or the UI cannot be exercised programmatically — say so explicitly. Do not present unverified behaviour as confirmed.
+
+## Complete Implementation Chains
+
+Every user-facing feature is a chain: a trigger connects to a handler connects to an action. All links in the chain must exist before the feature is considered done.
+
+**Common failure modes:**
+
+- A button, keyboard shortcut, or menu item is documented and visible — but no handler exists for it
+- A handler is registered (event, callback, delegate) — but nothing ever calls it
+- An action is implemented — but no trigger connects it to user input
+- An interface or abstract method is declared — but left as a no-op or stub and never noticed
+
+**The rule:** When you add any one part of a chain, locate and verify all the other parts. If they do not exist, either create them in the same change or explicitly flag them as missing.
+
+Chains to audit in this codebase:
+- UI label / key hint → key handler case → trigger method → registered callback → executing action
+- Interface method / abstract member → all implementations → all call sites
+- Event subscription → event firing → observable side effect
+
+## Document–Code Consistency
+
+Everything the user can read — UI hints, help text, comments, XML docs, error messages — is a promise about what the code does. Broken promises are bugs.
+
+**Rule:** Any text visible to a user that describes a behaviour, shortcut, or feature must have a corresponding implementation. If the implementation does not exist, remove the text. If the text is wrong, fix it. Never leave documentation and code in disagreement.
+
+Examples:
+- A tooltip says "Press R to replay" → `ConsoleKey.R` must have a handler
+- An XML doc says "Throws X when Y" → the code must throw X when Y
+- A README says "Supports Z format" → Z must be supported
+
+## No Silent Failures
+
+When a user-triggered operation cannot proceed because a precondition is not met, always surface feedback. A method that silently returns accomplishes nothing except making the user believe the feature is broken.
+
+**Rule:** Every early-return guard in a user-facing operation must explain why it returned. Use status messages, error indicators, or log output — whatever is appropriate for the context. Never return silently.
+
+```csharp
+// ❌ User sees nothing, concludes the feature is broken
+if (selected is null) return;
+
+// ✅ User sees "Select a row first"
+if (selected is null) { ShowHint("Select a row first"); return; }
+```
+
+This applies equally to:
+- User input handlers that silently drop input
+- API endpoints that return empty 200s instead of 404s
+- Background jobs that swallow exceptions
+- Validation that fails without reporting what was invalid
+
+## Carry-Forward Integrity
+
+When extending existing code, do not assume it is complete. Scaffold, boilerplate, and copy-pasted patterns frequently have missing links — stubs, unimplemented handlers, wired-but-never-called callbacks. The fact that existing code was not flagged as broken does not mean it works.
+
+**Rule:** When touching an existing feature, trace the full implementation chain — trigger → handler → action — and verify each link. Any gap you find is a pre-existing bug. Either fix it in the same change or leave an explicit note.
+
+## Verification Checklist
+
+Before marking any feature complete, confirm:
+
+- [ ] Every user-visible description of a behaviour has a corresponding implementation
+- [ ] Every registered handler/callback/listener is called by something
+- [ ] Every trigger (button, shortcut, event) connects to a handler
+- [ ] Every early return in a user-facing path provides user feedback
+- [ ] If runtime behaviour could not be verified, that is stated explicitly
diff --git a/.claude/rules/feature-completeness.md b/.claude/rules/feature-completeness.md
new file mode 120000
index 0000000..c79c159
--- /dev/null
+++ b/.claude/rules/feature-completeness.md
@@ -0,0 +1 @@
+../../.ai/rules/feature-completeness.md
\ No newline at end of file
diff --git a/.github/instructions/feature-completeness.instructions.md b/.github/instructions/feature-completeness.instructions.md
new file mode 120000
index 0000000..c79c159
--- /dev/null
+++ b/.github/instructions/feature-completeness.instructions.md
@@ -0,0 +1 @@
+../../.ai/rules/feature-completeness.md
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 01cbab2..3c5882b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,6 +12,7 @@
+
diff --git a/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs b/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs
index 266fa00..5ff7241 100644
--- a/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs
+++ b/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs
@@ -11,7 +11,7 @@ public class and_running_state_has_quarantined_value : Specification
void Because() => _isQuarantined = ListObserversCommand.IsQuarantined(new ObserverInformation
{
- RunningState = (ObserverRunningState)5
+ RunningState = ObserverRunningState.Quarantined
});
[Fact] void should_return_true() => _isQuarantined.ShouldBeTrue();
diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj
index 008d56a..219ff3f 100644
--- a/Source/Cli/Cli.csproj
+++ b/Source/Cli/Cli.csproj
@@ -24,6 +24,7 @@
+
diff --git a/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs b/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs
deleted file mode 100644
index b05f993..0000000
--- a/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright (c) Cratis. All rights reserved.
-// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-
-namespace Cratis.Cli.Commands.Chronicle.Workbench;
-
-///
-/// A frame on the workbench navigation stack, capturing view state before drilling into a detail view.
-///
-/// The view enum value (stored as int to avoid volatile boxing).
-/// The list cursor position in that view.
-/// The focused item identifier in that view.
-public readonly record struct NavFrame(int View, int SelectedIndex, string FocusedId);
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs
deleted file mode 100644
index 3411c7b..0000000
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (c) Cratis. All rights reserved.
-// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-
-namespace Cratis.Cli.Commands.Chronicle.Workbench;
-
-///
-/// The current action lifecycle state of the workbench.
-///
-public enum WorkbenchActionState
-{
- /// Normal operation — no action pending.
- None,
-
- /// An action is queued and awaiting user confirmation.
- AwaitingConfirmation,
-
- /// An action is being executed against the server.
- Executing,
-
- /// An action has completed; the result is available.
- Completed
-}
-
-///
-/// Describes a pending in-place action that requires user confirmation before execution.
-///
-/// Human-readable label shown in the confirmation prompt.
-/// Message displayed after successful execution.
-/// The gRPC call to invoke when the user confirms.
-public record PendingAction(
- string Description,
- string SuccessMessage,
- Func Execute);
-
-///
-/// Carries all render-relevant state for a single frame of the workbench, keeping the Build() signature stable.
-///
-/// The currently active view (primary or detail).
-/// The selected list-item index within the current view.
-/// The current refresh interval in seconds.
-/// Whether a data fetch is currently in progress.
-/// The current action lifecycle state.
-/// Description of the action awaiting confirmation, or .
-/// Result message after action completion, or .
-/// Whether the most recent action completed with an error.
-/// The identifier of the item shown in a detail view.
-/// The content scroll offset (line number) in detail views.
-/// Navigation breadcrumb path, e.g. ["Observers", "SomeProjection"].
-/// The active inline filter string, or empty when no filter is applied.
-/// Whether the user is actively typing a filter (entered via '/').
-/// Whether the event log is sorted ascending (oldest first) instead of the default descending (newest first).
-/// The current zero-based page index within the event log (50 events per page).
-public record WorkbenchRenderState(
- WorkbenchView View,
- int SelectedIndex,
- int Interval,
- bool IsRefreshing,
- WorkbenchActionState ActionState,
- string? PendingActionDescription,
- string? ActionResult,
- bool IsActionError = false,
- string FocusedId = "",
- int ScrollOffset = 0,
- IReadOnlyList? Breadcrumb = null,
- string FilterText = "",
- bool FilterInputMode = false,
- bool EventLogAscending = false,
- int EventLogPage = 0);
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs
new file mode 100644
index 0000000..0b195b3
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Configuration;
+using SharpConsoleUI.Drivers;
+using SharpConsoleUI.Panel;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Bootstraps and runs the Chronicle Workbench TUI application using SharpConsoleUI.
+///
+/// The data service for fetching workbench snapshots.
+/// The workbench command settings.
+/// The Chronicle gRPC service clients.
+/// Pre-fetched data snapshot to populate all views before the first frame is rendered.
+/// Persisted workbench state from the previous session.
+public class WorkbenchApp(WorkbenchDataService dataService, WorkbenchSettings settings, IServices services, WorkbenchData initialData, WorkbenchState state)
+{
+ ///
+ /// Runs the workbench TUI and blocks until the user exits.
+ ///
+ /// The exit code.
+ public int Run()
+ {
+ var windowSystem = new ConsoleWindowSystem(
+ new NetConsoleDriver(RenderMode.Buffer),
+ options: new ConsoleWindowSystemOptions(
+ TopPanelConfig: panel => panel.Left(Elements.StatusText(string.Empty)),
+ BottomPanelConfig: panel => panel.Left(Elements.StatusText(WorkbenchHints.BottomBar))));
+
+ windowSystem.PanelStateService.TopStatus = "◆ CHRONICLE WORKBENCH";
+
+ var mainWindow = new MainWindow(windowSystem, dataService, settings, services, initialData, state);
+ windowSystem.AddWindow(mainWindow.Build(), activateWindow: true);
+
+ return windowSystem.Run();
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs
new file mode 100644
index 0000000..aaa926d
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SColor = SharpConsoleUI.Color;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// SharpConsoleUI-compatible color constants for the workbench — midnight-blue palette with vibrant accents.
+///
+public static class WorkbenchColors
+{
+ /// Very deep midnight-navy background — the base window color.
+ public static readonly SColor Background = new(8, 12, 28, 255);
+
+ /// Deep navy surface — content pane background, slightly lighter than the window.
+ public static readonly SColor Surface = new(16, 22, 46, 255);
+
+ /// Primary foreground — blue-tinted white, easy on the eyes on dark backgrounds.
+ public static readonly SColor Foreground = new(220, 225, 250, 255);
+
+ /// Primary accent — electric blue, used for highlights, selected items, and borders.
+ public static readonly SColor Accent = new(100, 180, 255, 255);
+
+ /// Navigation pane selection background — rich royal blue.
+ public static readonly SColor SelectedBg = new(35, 70, 150, 255);
+
+ /// Muted secondary text — perceptible but not distracting.
+ public static readonly SColor Muted = new(90, 110, 160, 255);
+
+ /// Success / healthy state — vibrant mint green.
+ public static readonly SColor Success = new(100, 220, 130, 255);
+
+ /// Warning state — warm amber, used for the OBSERVATION nav section.
+ public static readonly SColor Warning = new(240, 190, 80, 255);
+
+ /// Danger / error state — bright coral-pink for failed partitions and errors.
+ public static readonly SColor Danger = new(255, 100, 130, 255);
+
+ /// Teal / cyan accent — used for the EVENTS nav section and event-type borders.
+ public static readonly SColor Teal = new(60, 220, 200, 255);
+
+ /// Mauve / violet accent — used for the PROJECTIONS nav section.
+ public static readonly SColor Mauve = new(200, 155, 255, 255);
+
+ /// Content pane border — subtle blue-violet, visible but not distracting.
+ public static readonly SColor ContentBorder = new(45, 65, 115, 255);
+
+ /// Dim table/panel border color — very subtle dark-blue chrome.
+ public static readonly SColor ChromeBorder = new(30, 40, 80, 255);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs
index c5e4e4d..5e5a350 100644
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs
@@ -1,62 +1,19 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-using System.Text.Json;
-using Cratis.Chronicle.Contracts.Jobs;
-using Cratis.Cli.Commands.Chronicle.ReadModels;
-
namespace Cratis.Cli.Commands.Chronicle.Workbench;
///
/// Interactive TUI workbench — a live-updating dashboard with full drill-down navigation and in-place
/// actions for observers, failed partitions, jobs, recommendations, event log, event types, and projections.
///
-[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with ← → or 1–8, drill into items with Enter, go back with Escape. Not suitable for scripting.")]
+[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with number keys 1–9 to switch tabs. Not suitable for scripting.")]
[CliCommand("workbench", "Open the interactive Chronicle workbench (live TUI dashboard)", Branch = typeof(ChronicleBranch))]
[CliExample("chronicle", "workbench")]
[CliExample("chronicle", "workbench", "--interval", "10")]
[CliExample("chronicle", "workbench", "-e", "my-event-store")]
public class WorkbenchCommand : ChronicleCommand
{
- /// Number of primary values (values < 100) — must be kept in sync with the enum.
- const int ViewCount = 11;
-
- /// Number of events displayed per page in the Event Log view.
- const int EventLogPageSize = 50;
-
- /// Total events fetched from the server for the Event Log — determines how many pages are available.
- const int EventLogFetchWindow = 500;
-
- static readonly JsonSerializerOptions _instanceJsonOptions = new() { WriteIndented = true };
-
- /// Nav stack — only touched by the input task (HandleKey). Render loop uses snapshot fields.
- readonly Stack _navStack = new();
-
- volatile int _currentView;
- volatile int _selectedIndex;
- volatile int _actionState;
- volatile int _scrollOffset;
- volatile int _isActionError;
- volatile int _filterInputMode;
- volatile int _eventLogAscending;
- volatile int _eventLogPage;
-
- ///
- /// Fired by the input task whenever the user presses a key; wakes the render loop immediately
- /// rather than waiting for the full refresh interval to elapse.
- ///
- TaskCompletionSource _keyPressSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
-
- PendingAction? _pendingAction;
- string _actionResult = string.Empty;
- string _focusedId = string.Empty;
- string _filter = string.Empty;
- string? _activeEventStore;
- string? _activeNamespace;
- IReadOnlyList _breadcrumb = [];
- IServices? _services;
- WorkbenchData? _lastData;
-
///
protected override bool UseStatusSpinner => false;
@@ -73,1098 +30,28 @@ protected override async Task ExecuteCommandAsync(IServices services, Workb
return ExitCodes.ValidationError;
}
- _services = services;
-
- using var cts = new CancellationTokenSource();
- Console.CancelKeyPress += (_, e) =>
+ // Restore persisted state from the previous session.
+ var state = WorkbenchState.Load();
+ if (settings.Interval == 5)
{
- e.Cancel = true;
- cts.Cancel();
- };
-
- var inputTask = Task.Run(
- async () =>
- {
- while (!cts.Token.IsCancellationRequested)
- {
- if (Console.KeyAvailable)
- {
- var pressedKey = Console.ReadKey(intercept: true);
- HandleKey(pressedKey, settings, cts);
- }
-
- try
- {
- await Task.Delay(50, cts.Token).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- }
- },
- cts.Token);
-
- var data = WorkbenchData.Loading(settings);
-
- await AnsiConsole.Live(WorkbenchRenderer.Build(data, LoadingState(settings)))
- .StartAsync(async ctx =>
- {
- while (!cts.Token.IsCancellationRequested)
- {
- var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- Interlocked.Exchange(ref _keyPressSignal, signal);
-
- ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: true)));
- ctx.Refresh();
-
- data = await FetchData(services, settings, _activeEventStore, _activeNamespace, (WorkbenchView)_currentView, _focusedId, cts.Token);
- _lastData = data;
-
- ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: false)));
- ctx.Refresh();
+ // Only apply saved interval when the user hasn't explicitly set one via --interval.
+ settings.Interval = state.Interval;
+ }
- try
- {
- await Task.WhenAny(
- Task.Delay(TimeSpan.FromSeconds(settings.Interval), cts.Token),
- signal.Task);
- }
- catch (OperationCanceledException)
- {
- break;
- }
+ var dataService = new WorkbenchDataService(services, settings);
- if (cts.Token.IsCancellationRequested) break;
+ // Pre-fetch before launching the window so every view has real data from the first frame —
+ // mirroring the old render-loop approach where data was fetched before the first render.
+ var initialData = await dataService.FetchAsync(null, null, null, CancellationToken.None).ConfigureAwait(false);
- ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: true)));
- ctx.Refresh();
- }
- });
+ var app = new WorkbenchApp(dataService, settings, services, initialData, state);
+ app.Run();
- try
- {
- await inputTask;
- }
- catch (OperationCanceledException)
- {
- }
+ // Persist final state so next session restores the same context.
+ state.Interval = settings.Interval;
+ state.Save();
AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Workbench closed.[/]");
return ExitCodes.Success;
}
-
- static async Task FetchData(
- IServices services,
- WorkbenchSettings settings,
- string? activeEventStore,
- string? activeNamespace,
- WorkbenchView currentView,
- string focusedId,
- CancellationToken ct)
- {
- var eventStore = activeEventStore ?? settings.ResolveEventStore();
- var ns = activeNamespace ?? settings.ResolveNamespace();
- var connectionString = settings.ResolveConnectionString();
-
- string? serverVersion = null;
- var isConnected = true;
-
- try
- {
- var versionInfo = await services.Server.GetVersionInfo();
- serverVersion = versionInfo.Version;
- }
- catch
- {
- isConnected = false;
- }
-
- var eventStoreNames = new List();
- try { eventStoreNames = [.. await services.EventStores.GetEventStores()]; }
- catch { }
-
- IReadOnlyList observers = [];
- try
- {
- observers = [.. await services.Observers.GetObservers(new AllObserversRequest
- {
- EventStore = eventStore,
- Namespace = ns
- })];
- }
- catch { }
-
- IReadOnlyList failedPartitions = [];
- try
- {
- failedPartitions = [.. await services.FailedPartitions.GetFailedPartitions(new GetFailedPartitionsRequest
- {
- EventStore = eventStore,
- Namespace = ns
- })];
- }
- catch { }
-
- IReadOnlyList jobs = [];
- try
- {
- jobs = [.. (await services.Jobs.GetJobs(new GetJobsRequest
- {
- EventStore = eventStore,
- Namespace = ns
- })) ?? []];
- }
- catch { }
-
- IReadOnlyList recommendations = [];
- try
- {
- recommendations = [.. await services.Recommendations.GetRecommendations(new GetRecommendationsRequest
- {
- EventStore = eventStore,
- Namespace = ns
- })];
- }
- catch { }
-
- ulong? tailSequenceNumber = null;
- try
- {
- var tail = await services.EventSequences.GetTailSequenceNumber(new GetTailSequenceNumberRequest
- {
- EventStore = eventStore,
- Namespace = ns,
- EventSequenceId = CliDefaults.DefaultEventSequenceId
- });
- tailSequenceNumber = tail.SequenceNumber == ulong.MaxValue ? null : tail.SequenceNumber;
- }
- catch { }
-
- IReadOnlyList eventTypeRegistrations = [];
- try
- {
- eventTypeRegistrations = [.. await services.EventTypes.GetAllRegistrations(
- new GetAllEventTypesRequest { EventStore = eventStore })];
- }
- catch { }
-
- IReadOnlyList projectionDefinitions = [];
- try
- {
- projectionDefinitions = [.. await services.Projections.GetAllDefinitions(
- new GetAllDefinitionsRequest { EventStore = eventStore })];
- }
- catch { }
-
- var projectionDeclarations = new Dictionary();
- try
- {
- var declarations = await services.Projections.GetAllDeclarations(
- new GetAllDeclarationsRequest { EventStore = eventStore });
- foreach (var d in declarations)
- {
- projectionDeclarations[d.Identifier] = d.Declaration ?? string.Empty;
- }
- }
- catch { }
-
- IReadOnlyList recentEvents = [];
- try
- {
- if (tailSequenceNumber > 0)
- {
- var fromSeq = tailSequenceNumber.Value >= EventLogFetchWindow
- ? tailSequenceNumber.Value - EventLogFetchWindow + 1
- : 0;
- var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber(
- new GetFromEventSequenceNumberRequest
- {
- EventStore = eventStore,
- Namespace = ns,
- EventSequenceId = CliDefaults.DefaultEventSequenceId,
- FromEventSequenceNumber = fromSeq
- });
- recentEvents = [.. eventsResp.Events.OrderByDescending(e => e.Context.SequenceNumber)];
- }
- }
- catch { }
-
- IReadOnlyList readModelDefinitions = [];
- try
- {
- var defs = await services.ReadModels.GetDefinitions(new GetDefinitionsRequest { EventStore = eventStore });
- readModelDefinitions = [.. defs.ReadModels.Select(rm => new WorkbenchReadModel(
- rm.ContainerName,
- rm.DisplayName,
- rm.Owner.ToString(),
- !string.Equals(rm.Owner.ToString(), "Client", StringComparison.Ordinal),
- rm.Source.ToString(),
- rm.Type?.Identifier ?? string.Empty))];
- }
- catch { }
-
- IReadOnlyList namespaceNames = [];
- try { namespaceNames = [.. await services.Namespaces.GetNamespaces(new GetNamespacesRequest { EventStore = eventStore })]; }
- catch { }
-
- IReadOnlyList readModelInstances = [];
- var readModelInstancesTotalCount = 0;
- string? readModelInstancesError = null;
- if (currentView == WorkbenchView.ReadModelDetail && !string.IsNullOrEmpty(focusedId))
- {
- try
- {
- var instResp = await services.ReadModels.GetInstances(new GetInstancesRequest
- {
- EventStore = eventStore,
- Namespace = ns,
- ReadModel = focusedId,
- Page = 0,
- PageSize = 20
- });
- readModelInstancesTotalCount = (int)Math.Min(instResp.TotalCount, int.MaxValue);
- readModelInstances = [.. (instResp.Instances ?? [])
- .Select(ReadModelJsonCleaner.CleanInstance)
- .Where(o => o is not null)
- .Select(o => JsonSerializer.Serialize(o, _instanceJsonOptions))];
- }
- catch (Exception ex)
- {
- readModelInstancesError = ex.Message;
- }
- }
-
- return new WorkbenchData(
- ConnectionString: connectionString,
- EventStore: eventStore,
- Namespace: ns,
- IsConnected: isConnected,
- ServerVersion: serverVersion,
- EventStoreNames: eventStoreNames,
- Observers: observers,
- FailedPartitions: failedPartitions,
- Jobs: jobs,
- Recommendations: recommendations,
- TailSequenceNumber: tailSequenceNumber,
- CapturedAt: DateTimeOffset.Now,
- FetchError: null,
- EventTypeRegistrations: eventTypeRegistrations,
- ProjectionDefinitions: projectionDefinitions,
- ProjectionDeclarations: projectionDeclarations,
- RecentEvents: recentEvents,
- ReadModelDefinitions: readModelDefinitions,
- NamespaceNames: namespaceNames,
- ReadModelInstances: readModelInstances,
- ReadModelInstancesTotalCount: readModelInstancesTotalCount,
- ReadModelInstancesError: readModelInstancesError);
- }
-
- static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch
- {
- ObserverRunningState.Disconnected => 0,
- ObserverRunningState.Replaying => 1,
- ObserverRunningState.Active => 2,
- ObserverRunningState.Suspended => 3,
- _ => 4
- };
-
- static string GetViewLabel(WorkbenchView view) => view switch
- {
- WorkbenchView.Overview => "Overview",
- WorkbenchView.Observers => "Observers",
- WorkbenchView.FailedPartitions => "Failures",
- WorkbenchView.Jobs => "Jobs",
- WorkbenchView.Recommendations => "Recommendations",
- WorkbenchView.EventLog => "Event Log",
- WorkbenchView.EventTypes => "Event Types",
- WorkbenchView.Projections => "Projections",
- WorkbenchView.ReadModels => "Read Models",
- WorkbenchView.EventStores => "Event Stores",
- WorkbenchView.Namespaces => "Namespaces",
- WorkbenchView.ObserverDetail => "Observer",
- WorkbenchView.FailedPartitionDetail => "Failure",
- WorkbenchView.EventDetail => "Event",
- WorkbenchView.EventTypeDetail => "Event Type",
- WorkbenchView.ProjectionDetail => "Projection",
- WorkbenchView.ReadModelDetail => "Read Model",
- _ => string.Empty
- };
-
- static WorkbenchRenderState LoadingState(WorkbenchSettings settings) =>
- new(WorkbenchView.Overview, 0, settings.Interval, true, WorkbenchActionState.None, null, null);
-
- static string TruncateForPrompt(string s) => s.Length <= 60 ? s : s[..57] + "…";
-
- static bool HasSubNavigation(WorkbenchView view) => view == WorkbenchView.ObserverDetail;
-
- static bool IsFilterableView(WorkbenchView view) =>
- view is WorkbenchView.Observers or WorkbenchView.EventTypes
- or WorkbenchView.EventLog or WorkbenchView.Projections
- or WorkbenchView.ReadModels;
-
- int GetMaxSelectedIndex()
- {
- var view = (WorkbenchView)_currentView;
- var data = _lastData;
- if (data is null) return 0;
- if (view == WorkbenchView.EventLog)
- {
- var pageStart = _eventLogPage * EventLogPageSize;
- return Math.Max(0, Math.Min(EventLogPageSize, data.RecentEvents.Count - pageStart) - 1);
- }
- return Math.Max(0, view switch
- {
- WorkbenchView.Observers => data.Observers.Count - 1,
- WorkbenchView.FailedPartitions => data.FailedPartitions.Count - 1,
- WorkbenchView.Jobs => data.Jobs.Count - 1,
- WorkbenchView.Recommendations => data.Recommendations.Count - 1,
- WorkbenchView.EventTypes => data.EventTypeRegistrations.Count - 1,
- WorkbenchView.Projections => data.ProjectionDefinitions.Count - 1,
- WorkbenchView.ReadModels => data.ReadModelDefinitions.Count - 1,
- WorkbenchView.EventStores => data.EventStoreNames.Count - 1,
- WorkbenchView.Namespaces => data.NamespaceNames.Count - 1,
- WorkbenchView.ObserverDetail => Math.Max(0, (data.Observers
- .FirstOrDefault(o => o.Id == _focusedId)?.EventTypes?.Count() ?? 1) - 1),
- _ => 0
- });
- }
-
- WorkbenchRenderState RenderState(WorkbenchSettings settings, bool isRefreshing) =>
- new(
- View: (WorkbenchView)_currentView,
- SelectedIndex: _selectedIndex,
- Interval: settings.Interval,
- IsRefreshing: isRefreshing,
- ActionState: (WorkbenchActionState)_actionState,
- PendingActionDescription: _pendingAction?.Description,
- ActionResult: _actionResult,
- IsActionError: _isActionError != 0,
- FocusedId: _focusedId,
- ScrollOffset: _scrollOffset,
- Breadcrumb: _breadcrumb,
- FilterText: _filter,
- FilterInputMode: _filterInputMode != 0,
- EventLogAscending: _eventLogAscending != 0,
- EventLogPage: _eventLogPage);
-
- ObserverInformation? GetSelectedObserver() =>
- _lastData?.Observers
- .OrderBy(ObserverSortOrder)
- .ThenBy(o => o.Id)
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- ObserverInformation? FindObserverById(string id) =>
- _lastData?.Observers.FirstOrDefault(o => o.Id == id);
-
- FailedPartition? GetSelectedFailedPartition() =>
- _lastData?.FailedPartitions
- .OrderByDescending(fp => fp.Attempts.Count())
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- FailedPartition? FindFailedPartitionByFocusedId(string focusedId)
- {
- var sep = focusedId.IndexOf('/');
- if (sep < 0) return null;
- var obsId = focusedId[..sep];
- var partition = focusedId[(sep + 1)..];
- return _lastData?.FailedPartitions
- .FirstOrDefault(fp => fp.ObserverId == obsId && fp.Partition == partition);
- }
-
- Job? GetSelectedJob() =>
- _lastData?.Jobs
- .OrderBy(j => j.Status.ToString())
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- Recommendation? GetSelectedRecommendation() =>
- _lastData?.Recommendations
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- AppendedEvent? GetSelectedEvent() =>
- _lastData?.RecentEvents
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- EventTypeRegistration? GetSelectedEventTypeRegistration() =>
- _lastData?.EventTypeRegistrations
- .OrderBy(r => r.Type.Id, StringComparer.OrdinalIgnoreCase)
- .ThenBy(r => r.Type.Generation)
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- ProjectionDefinition? GetSelectedProjectionDefinition() =>
- _lastData?.ProjectionDefinitions
- .OrderBy(d => d.Identifier, StringComparer.OrdinalIgnoreCase)
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- WorkbenchReadModel? GetSelectedReadModel() =>
- _lastData?.ReadModelDefinitions
- .OrderBy(d => d.ContainerName, StringComparer.OrdinalIgnoreCase)
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- string? GetSelectedEventStoreName() =>
- _lastData?.EventStoreNames
- .Order(StringComparer.OrdinalIgnoreCase)
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- string? GetSelectedNamespaceName() =>
- _lastData?.NamespaceNames
- .Order(StringComparer.OrdinalIgnoreCase)
- .Skip(_selectedIndex)
- .FirstOrDefault();
-
- void PushNav()
- {
- _navStack.Push(new NavFrame(_currentView, _selectedIndex, _focusedId));
- }
-
- List BuildBreadcrumb()
- {
- if (_navStack.Count == 0) return [];
- var path = new List();
- foreach (var frame in _navStack.Reverse())
- {
- path.Add(GetViewLabel((WorkbenchView)frame.View));
- }
-
- if (!string.IsNullOrEmpty(_focusedId)) path.Add(TruncateForPrompt(_focusedId));
- return path;
- }
-
- void NavigateToDetail(WorkbenchView detailView, string focusedId)
- {
- PushNav();
- _focusedId = focusedId;
- _currentView = (int)detailView;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _breadcrumb = BuildBreadcrumb();
- }
-
- void NavigateBack()
- {
- var frame = _navStack.Pop();
- _currentView = frame.View;
- _selectedIndex = frame.SelectedIndex;
- _focusedId = frame.FocusedId;
- _scrollOffset = 0;
- _breadcrumb = BuildBreadcrumb();
- }
-
- void SetPendingAction(PendingAction action)
- {
- _pendingAction = action;
- _actionState = (int)WorkbenchActionState.AwaitingConfirmation;
- _keyPressSignal.TrySetResult();
- }
-
- async Task ExecuteActionAsync(CancellationToken ct)
- {
- var action = _pendingAction;
- if (action is null) return;
-
- _isActionError = 0;
- _actionState = (int)WorkbenchActionState.Executing;
- _keyPressSignal.TrySetResult();
-
- try
- {
- await action.Execute(ct);
- _actionResult = action.SuccessMessage;
- }
- catch (OperationCanceledException)
- {
- _actionState = (int)WorkbenchActionState.None;
- return;
- }
- catch (Exception ex)
- {
- var msg = ex.Message;
- _actionResult = msg.Length > 100 ? msg[..100] + "…" : msg;
- _isActionError = 1;
- }
-
- _pendingAction = null;
- _actionState = (int)WorkbenchActionState.Completed;
- _keyPressSignal.TrySetResult();
- }
-
- void HandleKey(ConsoleKeyInfo keyInfo, WorkbenchSettings settings, CancellationTokenSource cts)
- {
- var key = keyInfo.Key;
- var actionState = (WorkbenchActionState)_actionState;
- var view = (WorkbenchView)_currentView;
-
- // When a completed action is showing its result, any key dismisses it.
- if (actionState == WorkbenchActionState.Completed)
- {
- _actionState = (int)WorkbenchActionState.None;
- _actionResult = string.Empty;
- _keyPressSignal.TrySetResult();
- return;
- }
-
- // While executing, ignore all input except quit.
- if (actionState == WorkbenchActionState.Executing)
- {
- if (key == ConsoleKey.Q) cts.Cancel();
- return;
- }
-
- // Confirmation prompt — only Y/N/Escape are valid.
- if (actionState == WorkbenchActionState.AwaitingConfirmation)
- {
- switch (key)
- {
- case ConsoleKey.Y:
- _ = Task.Run(() => ExecuteActionAsync(cts.Token), cts.Token);
- break;
- case ConsoleKey.N:
- case ConsoleKey.Escape:
- _pendingAction = null;
- _actionState = (int)WorkbenchActionState.None;
- _keyPressSignal.TrySetResult();
- break;
- }
-
- return;
- }
-
- var isDetail = (int)view >= 100;
-
- // Filter input mode — route all input to the filter until Enter or Escape exits it.
- if (_filterInputMode != 0)
- {
- switch (key)
- {
- case ConsoleKey.Escape:
- _filterInputMode = 0;
- _filter = string.Empty;
- _selectedIndex = 0;
- break;
- case ConsoleKey.Enter:
- _filterInputMode = 0;
- break;
- case ConsoleKey.Backspace:
- if (!string.IsNullOrEmpty(_filter))
- _filter = _filter[..^1];
- break;
- case ConsoleKey.UpArrow:
- _selectedIndex = Math.Max(0, _selectedIndex - 1);
- break;
- case ConsoleKey.DownArrow:
- _selectedIndex++;
- break;
- default:
- if (keyInfo.KeyChar >= ' ')
- {
- _filter += keyInfo.KeyChar;
- _selectedIndex = 0;
- }
- break;
- }
- _keyPressSignal.TrySetResult();
- return;
- }
-
- switch (key)
- {
- // Primary view number keys — always clear the nav stack and jump directly.
- case ConsoleKey.D1:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Overview;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.D2:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Observers;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.D3:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.FailedPartitions;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.D4:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Jobs;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.D5:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Recommendations;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.D6:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.EventLog;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _eventLogPage = 0;
- _breadcrumb = [];
- break;
- case ConsoleKey.D7:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.EventTypes;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.D8:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Projections;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _filterInputMode = 0;
- _breadcrumb = [];
- break;
- case ConsoleKey.D9:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.ReadModels;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _filterInputMode = 0;
- _breadcrumb = [];
- break;
- case ConsoleKey.D0:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.EventStores;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _focusedId = string.Empty;
- _filter = string.Empty;
- _filterInputMode = 0;
- _breadcrumb = [];
- break;
-
- // Left/right — cycle through primary views only (detail views excluded).
- case ConsoleKey.LeftArrow when !isDetail:
- _currentView = (_currentView - 1 + ViewCount) % ViewCount;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _filter = string.Empty;
- _filterInputMode = 0;
- _eventLogPage = 0;
- break;
- case ConsoleKey.RightArrow when !isDetail:
- _currentView = (_currentView + 1) % ViewCount;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _filter = string.Empty;
- _filterInputMode = 0;
- _eventLogPage = 0;
- break;
-
- // Up/down — navigate list in primary views and sub-navigation detail views; scroll in other detail views.
- case ConsoleKey.UpArrow:
- case ConsoleKey.K:
- if (isDetail && !HasSubNavigation(view))
- _scrollOffset = Math.Max(0, _scrollOffset - 1);
- else
- _selectedIndex = Math.Max(0, _selectedIndex - 1);
- break;
- case ConsoleKey.DownArrow:
- if (isDetail && !HasSubNavigation(view))
- _scrollOffset = Math.Min(2000, _scrollOffset + 1);
- else
- _selectedIndex = Math.Min(GetMaxSelectedIndex(), _selectedIndex + 1);
- break;
-
- // Enter — drill into detail view for selected item.
- case ConsoleKey.Enter:
- HandleEnterKey(settings);
- return; // HandleEnterKey fires signal
-
- // Escape — clear filter if active; otherwise pop nav stack or reset selection.
- case ConsoleKey.Escape:
- if (!string.IsNullOrEmpty(_filter))
- {
- _filter = string.Empty;
- _selectedIndex = 0;
- }
- else if (_navStack.Count > 0)
- {
- NavigateBack();
- }
- else
- {
- _selectedIndex = 0;
- _scrollOffset = 0;
- }
-
- break;
-
- // Backspace — remove last filter character when a filter is active.
- case ConsoleKey.Backspace:
- if (!string.IsNullOrEmpty(_filter))
- {
- _filter = _filter[..^1];
- _selectedIndex = 0;
- }
-
- break;
-
- // Interval adjustment.
- case ConsoleKey.OemPlus:
- case ConsoleKey.Add:
- settings.Interval = Math.Min(60, settings.Interval + 1);
- break;
- case ConsoleKey.OemMinus:
- case ConsoleKey.Subtract:
- settings.Interval = Math.Max(1, settings.Interval - 1);
- break;
-
- case ConsoleKey.Q:
- cts.Cancel();
- return;
-
- // --- In-view action keys ---
- case ConsoleKey.R when view == WorkbenchView.Observers:
- case ConsoleKey.R when view == WorkbenchView.ObserverDetail:
- {
- var obs = view == WorkbenchView.ObserverDetail
- ? FindObserverById(_focusedId)
- : GetSelectedObserver();
- if (obs is not null)
- {
- SetPendingAction(new PendingAction(
- $"Replay observer '{TruncateForPrompt(obs.Id)}'",
- $"Replay started for observer '{TruncateForPrompt(obs.Id)}'",
- ct => _services!.Observers.Replay(new Replay
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- ObserverId = obs.Id,
- EventSequenceId = CliDefaults.DefaultEventSequenceId
- })));
- }
-
- return;
- }
-
- case ConsoleKey.T when view == WorkbenchView.FailedPartitions:
- case ConsoleKey.T when view == WorkbenchView.FailedPartitionDetail:
- {
- var fp = view == WorkbenchView.FailedPartitionDetail
- ? FindFailedPartitionByFocusedId(_focusedId)
- : GetSelectedFailedPartition();
- if (fp is not null)
- {
- SetPendingAction(new PendingAction(
- $"Retry partition '{fp.Partition}' of '{TruncateForPrompt(fp.ObserverId)}'",
- $"Retry started for partition '{fp.Partition}'",
- ct => _services!.Observers.RetryPartition(new RetryPartition
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- ObserverId = fp.ObserverId,
- Partition = fp.Partition,
- EventSequenceId = CliDefaults.DefaultEventSequenceId
- })));
- }
-
- return;
- }
-
- case ConsoleKey.P when view == WorkbenchView.FailedPartitions:
- case ConsoleKey.P when view == WorkbenchView.FailedPartitionDetail:
- {
- var fp = view == WorkbenchView.FailedPartitionDetail
- ? FindFailedPartitionByFocusedId(_focusedId)
- : GetSelectedFailedPartition();
- if (fp is not null)
- {
- SetPendingAction(new PendingAction(
- $"Replay partition '{fp.Partition}' of '{TruncateForPrompt(fp.ObserverId)}'",
- $"Replay started for partition '{fp.Partition}'",
- ct => _services!.Observers.ReplayPartition(new ReplayPartition
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- ObserverId = fp.ObserverId,
- Partition = fp.Partition,
- EventSequenceId = CliDefaults.DefaultEventSequenceId
- })));
- }
-
- return;
- }
-
- // In ObserverDetail, P navigates to the projection definition for projection-type observers.
- case ConsoleKey.P when view == WorkbenchView.ObserverDetail:
- {
- var obs = FindObserverById(_focusedId);
- if (obs is not null && _lastData is not null
- && obs.Type.ToString().Contains("Projection", StringComparison.OrdinalIgnoreCase))
- {
- var projDef = _lastData.ProjectionDefinitions
- .FirstOrDefault(d => string.Equals(d.Identifier, obs.Id, StringComparison.OrdinalIgnoreCase));
- if (projDef is not null)
- NavigateToDetail(WorkbenchView.ProjectionDetail, projDef.Identifier);
- }
-
- break;
- }
-
- case ConsoleKey.S when view == WorkbenchView.Jobs:
- {
- var job = GetSelectedJob();
- if (job is not null)
- {
- SetPendingAction(new PendingAction(
- $"Stop job '{TruncateForPrompt(job.Type ?? job.Id.ToString())}'",
- "Job stopped",
- ct => _services!.Jobs.Stop(new StopJob
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- JobId = job.Id
- })));
- }
-
- return;
- }
-
- case ConsoleKey.U when view == WorkbenchView.Jobs:
- {
- var job = GetSelectedJob();
- if (job is not null)
- {
- SetPendingAction(new PendingAction(
- $"Resume job '{TruncateForPrompt(job.Type ?? job.Id.ToString())}'",
- "Job resumed",
- ct => _services!.Jobs.Resume(new ResumeJob
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- JobId = job.Id
- })));
- }
-
- return;
- }
-
- case ConsoleKey.A when view == WorkbenchView.Recommendations:
- {
- var rec = GetSelectedRecommendation();
- if (rec is not null)
- {
- SetPendingAction(new PendingAction(
- $"Apply recommendation '{TruncateForPrompt(rec.Name ?? rec.Id.ToString())}'",
- "Recommendation applied",
- ct => _services!.Recommendations.Perform(new Perform
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- RecommendationId = rec.Id
- })));
- }
-
- return;
- }
-
- case ConsoleKey.I when view == WorkbenchView.Recommendations:
- {
- var rec = GetSelectedRecommendation();
- if (rec is not null)
- {
- SetPendingAction(new PendingAction(
- $"Ignore recommendation '{TruncateForPrompt(rec.Name ?? rec.Id.ToString())}'",
- "Recommendation ignored",
- ct => _services!.Recommendations.Ignore(new Perform
- {
- EventStore = settings.ResolveEventStore(),
- Namespace = settings.ResolveNamespace(),
- RecommendationId = rec.Id
- })));
- }
-
- return;
- }
-
- // In EventDetail, T navigates to the event type detail view.
- case ConsoleKey.T when view == WorkbenchView.EventDetail:
- var evtForType = _lastData?.RecentEvents
- .FirstOrDefault(e => e.Context.SequenceNumber.ToString() == _focusedId);
- var etToNav = evtForType?.Context.EventType;
- if (etToNav is not null)
- NavigateToDetail(WorkbenchView.EventTypeDetail, $"{etToNav.Id}+{etToNav.Generation}");
- break;
-
- // Event Log sort order toggle — also resets to page 0.
- case ConsoleKey.S when view == WorkbenchView.EventLog:
- _eventLogAscending = 1 - _eventLogAscending;
- _eventLogPage = 0;
- _selectedIndex = 0;
- break;
-
- // Event Log paging — PageDown goes to older events, PageUp goes to newer.
- case ConsoleKey.PageDown when view == WorkbenchView.EventLog:
- var totalEvents = _lastData?.RecentEvents.Count ?? 0;
- var maxPage = Math.Max(0, (totalEvents - 1) / EventLogPageSize);
- _eventLogPage = Math.Min(maxPage, _eventLogPage + 1);
- _selectedIndex = 0;
- break;
-
- case ConsoleKey.PageUp when view == WorkbenchView.EventLog:
- _eventLogPage = Math.Max(0, _eventLogPage - 1);
- _selectedIndex = 0;
- break;
-
- // Quick navigation to context-switching views from any primary view.
- case ConsoleKey.E when !isDetail:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.EventStores;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _filter = string.Empty;
- _filterInputMode = 0;
- _focusedId = string.Empty;
- _breadcrumb = [];
- break;
- case ConsoleKey.N when !isDetail:
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Namespaces;
- _selectedIndex = 0;
- _scrollOffset = 0;
- _filter = string.Empty;
- _filterInputMode = 0;
- _focusedId = string.Empty;
- _breadcrumb = [];
- break;
-
- // '/' activates inline filter mode in filterable views.
- default:
- if (!isDetail && IsFilterableView(view) && keyInfo.KeyChar == '/')
- {
- _filterInputMode = 1;
- _filter = string.Empty;
- _selectedIndex = 0;
- }
-
- break;
- }
-
- _keyPressSignal.TrySetResult();
- }
-
- void HandleEnterKey(WorkbenchSettings settings)
- {
- _filter = string.Empty;
-
- switch ((WorkbenchView)_currentView)
- {
- case WorkbenchView.Observers:
- if (GetSelectedObserver() is { } obs)
- NavigateToDetail(WorkbenchView.ObserverDetail, obs.Id);
- break;
-
- case WorkbenchView.ObserverDetail:
- var focusedObs = FindObserverById(_focusedId);
- var eventTypes = (focusedObs?.EventTypes ?? [])
- .OrderBy(et => et.Id, StringComparer.OrdinalIgnoreCase)
- .ToList();
- if (_selectedIndex < eventTypes.Count)
- {
- var et = eventTypes[_selectedIndex];
- NavigateToDetail(WorkbenchView.EventTypeDetail, $"{et.Id}+{et.Generation}");
- }
-
- break;
-
- case WorkbenchView.FailedPartitions:
- if (GetSelectedFailedPartition() is { } fp)
- NavigateToDetail(WorkbenchView.FailedPartitionDetail, $"{fp.ObserverId}/{fp.Partition}");
- break;
-
- case WorkbenchView.EventLog:
- if (GetSelectedEvent() is { } evt)
- NavigateToDetail(WorkbenchView.EventDetail, evt.Context.SequenceNumber.ToString());
- break;
-
- case WorkbenchView.EventTypes:
- if (GetSelectedEventTypeRegistration() is { } et2)
- NavigateToDetail(WorkbenchView.EventTypeDetail, $"{et2.Type.Id}+{et2.Type.Generation}");
- break;
-
- case WorkbenchView.Projections:
- if (GetSelectedProjectionDefinition() is { } proj)
- NavigateToDetail(WorkbenchView.ProjectionDetail, proj.Identifier);
- break;
-
- case WorkbenchView.ReadModels:
- if (GetSelectedReadModel() is { } rm)
- NavigateToDetail(WorkbenchView.ReadModelDetail, rm.ContainerName);
- break;
-
- case WorkbenchView.EventStores:
- var storeName = GetSelectedEventStoreName();
- if (storeName is not null)
- {
- _activeEventStore = storeName;
- _activeNamespace = null;
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Overview;
- _selectedIndex = 0;
- _filter = string.Empty;
- _filterInputMode = 0;
- _focusedId = string.Empty;
- _breadcrumb = [];
- }
- break;
-
- case WorkbenchView.Namespaces:
- var nsName = GetSelectedNamespaceName();
- if (nsName is not null)
- {
- _activeNamespace = nsName;
- _navStack.Clear();
- _currentView = (int)WorkbenchView.Overview;
- _selectedIndex = 0;
- _filter = string.Empty;
- _filterInputMode = 0;
- _focusedId = string.Empty;
- _breadcrumb = [];
- }
- break;
- }
-
- _keyPressSignal.TrySetResult();
- }
}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs
index 169a44f..75f0b22 100644
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs
@@ -1,7 +1,9 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+using Cratis.Chronicle.Contracts.Identities;
using Cratis.Chronicle.Contracts.Jobs;
+using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions;
namespace Cratis.Cli.Commands.Chronicle.Workbench;
@@ -47,6 +49,10 @@ public record WorkbenchReadModel(
/// Instances for the currently focused read model (populated in ReadModelDetail view only).
/// Total instance count for the currently focused read model.
/// Error message from the last read model instances fetch, or .
+/// All registered OAuth applications on the Chronicle server.
+/// All registered users on the Chronicle server.
+/// All known identities in the current event store namespace.
+/// All event store subscriptions configured for the current event store.
public record WorkbenchData(
string ConnectionString,
string EventStore,
@@ -69,7 +75,11 @@ public record WorkbenchData(
IReadOnlyList NamespaceNames,
IReadOnlyList ReadModelInstances,
int ReadModelInstancesTotalCount,
- string? ReadModelInstancesError)
+ string? ReadModelInstancesError,
+ IReadOnlyList Applications,
+ IReadOnlyList Users,
+ IReadOnlyList Identities,
+ IReadOnlyList EventStoreSubscriptions)
{
///
/// Gets the number of observers in the state.
@@ -118,5 +128,9 @@ public record WorkbenchData(
NamespaceNames: [],
ReadModelInstances: [],
ReadModelInstancesTotalCount: 0,
- ReadModelInstancesError: null);
+ ReadModelInstancesError: null,
+ Applications: [],
+ Users: [],
+ Identities: [],
+ EventStoreSubscriptions: []);
}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs
new file mode 100644
index 0000000..573b363
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs
@@ -0,0 +1,383 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+using Cratis.Chronicle.Contracts.Identities;
+using Cratis.Chronicle.Contracts.Jobs;
+using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions;
+using Cratis.Cli.Commands.Chronicle.ReadModels;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Fetches all Chronicle server data needed by the workbench in a single async snapshot.
+/// All independent gRPC calls are executed in parallel to minimise fetch latency.
+///
+/// The Chronicle gRPC service clients.
+/// The resolved workbench settings (connection string, event store, namespace).
+public class WorkbenchDataService(IServices services, WorkbenchSettings settings)
+{
+ /// Total events fetched from the server per refresh — determines how many pages are available in the Event Log view.
+ public const int EventLogFetchWindow = 500;
+
+ static readonly JsonSerializerOptions _instanceJsonOptions = new() { WriteIndented = true };
+
+ ///
+ /// Fetches a point-in-time snapshot of all data from the Chronicle server.
+ /// All independent gRPC calls run in parallel; only the recent-events fetch waits on the tail-sequence result.
+ ///
+ /// Override for the active event store (null uses settings default).
+ /// Override for the active namespace (null uses settings default).
+ /// Container name when fetching read model instances (non-null triggers an instances fetch).
+ /// Cancellation token.
+ /// A snapshot.
+ public async Task FetchAsync(
+ string? activeEventStore,
+ string? activeNamespace,
+ string? readModelContainerName,
+ CancellationToken ct)
+ {
+ var eventStore = activeEventStore ?? settings.ResolveEventStore();
+ var ns = activeNamespace ?? settings.ResolveNamespace();
+ var connectionString = settings.ResolveConnectionString();
+
+ // Run all independent calls in parallel.
+ var versionTask = FetchVersionAsync();
+ var eventStoresTask = FetchEventStoresAsync();
+ var observersTask = FetchObserversAsync(eventStore, ns);
+ var failedPartitionsTask = FetchFailedPartitionsAsync(eventStore, ns);
+ var jobsTask = FetchJobsAsync(eventStore, ns);
+ var recommendationsTask = FetchRecommendationsAsync(eventStore, ns);
+ var tailTask = FetchTailSequenceNumberAsync(eventStore, ns);
+ var eventTypesTask = FetchEventTypesAsync(eventStore);
+ var projectionsTask = FetchProjectionDefinitionsAsync(eventStore);
+ var declarationsTask = FetchProjectionDeclarationsAsync(eventStore);
+ var readModelsTask = FetchReadModelDefinitionsAsync(eventStore);
+ var namespacesTask = FetchNamespacesAsync(eventStore);
+ var applicationsTask = FetchApplicationsAsync();
+ var usersTask = FetchUsersAsync();
+ var identitiesTask = FetchIdentitiesAsync(eventStore, ns);
+ var subscriptionsTask = FetchSubscriptionsAsync(eventStore);
+
+ await Task.WhenAll(versionTask, eventStoresTask, observersTask, failedPartitionsTask, jobsTask, recommendationsTask, tailTask, eventTypesTask, projectionsTask, declarationsTask, readModelsTask, namespacesTask, applicationsTask, usersTask, identitiesTask, subscriptionsTask).ConfigureAwait(false);
+
+ // All tasks are complete — awaiting them returns immediately.
+ var (serverVersion, isConnected) = await versionTask.ConfigureAwait(false);
+ var tailSequenceNumber = await tailTask.ConfigureAwait(false);
+ var eventStoreNames = await eventStoresTask.ConfigureAwait(false);
+ var observers = await observersTask.ConfigureAwait(false);
+ var failedPartitions = await failedPartitionsTask.ConfigureAwait(false);
+ var jobs = await jobsTask.ConfigureAwait(false);
+ var recommendations = await recommendationsTask.ConfigureAwait(false);
+ var eventTypeRegistrations = await eventTypesTask.ConfigureAwait(false);
+ var projectionDefinitions = await projectionsTask.ConfigureAwait(false);
+ var projectionDeclarations = await declarationsTask.ConfigureAwait(false);
+ var readModelDefinitions = await readModelsTask.ConfigureAwait(false);
+ var namespaceNames = await namespacesTask.ConfigureAwait(false);
+ var applications = await applicationsTask.ConfigureAwait(false);
+ var users = await usersTask.ConfigureAwait(false);
+ var identities = await identitiesTask.ConfigureAwait(false);
+ var subscriptions = await subscriptionsTask.ConfigureAwait(false);
+
+ // Recent events depends on the tail — fetch after the parallel batch.
+ var recentEvents = await FetchRecentEventsAsync(eventStore, ns, tailSequenceNumber).ConfigureAwait(false);
+
+ // Read model instances are optional and fetched only when a container is specified.
+ var (readModelInstances, readModelInstancesTotalCount, readModelInstancesError) =
+ await FetchReadModelInstancesAsync(eventStore, ns, readModelContainerName).ConfigureAwait(false);
+
+ return new WorkbenchData(
+ ConnectionString: connectionString,
+ EventStore: eventStore,
+ Namespace: ns,
+ IsConnected: isConnected,
+ ServerVersion: serverVersion,
+ EventStoreNames: eventStoreNames,
+ Observers: observers,
+ FailedPartitions: failedPartitions,
+ Jobs: jobs,
+ Recommendations: recommendations,
+ TailSequenceNumber: tailSequenceNumber,
+ CapturedAt: DateTimeOffset.Now,
+ FetchError: null,
+ EventTypeRegistrations: eventTypeRegistrations,
+ ProjectionDefinitions: projectionDefinitions,
+ ProjectionDeclarations: projectionDeclarations,
+ RecentEvents: recentEvents,
+ ReadModelDefinitions: readModelDefinitions,
+ NamespaceNames: namespaceNames,
+ ReadModelInstances: readModelInstances,
+ ReadModelInstancesTotalCount: readModelInstancesTotalCount,
+ ReadModelInstancesError: readModelInstancesError,
+ Applications: applications,
+ Users: users,
+ Identities: identities,
+ EventStoreSubscriptions: subscriptions);
+ }
+
+ ///
+ /// Fetches only the new events appended since for the live event stream.
+ ///
+ /// The last known sequence number; only events with higher numbers are returned.
+ /// Override for the active event store (null uses settings default).
+ /// Override for the active namespace (null uses settings default).
+ /// Cancellation token.
+ /// New events ordered oldest-first, or an empty list if none.
+ public async Task> FetchNewEventsAsync(
+ ulong afterSequenceNumber,
+ string? activeEventStore,
+ string? activeNamespace,
+ CancellationToken ct)
+ {
+ var eventStore = activeEventStore ?? settings.ResolveEventStore();
+ var ns = activeNamespace ?? settings.ResolveNamespace();
+
+ try
+ {
+ var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber(
+ new GetFromEventSequenceNumberRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId,
+ FromEventSequenceNumber = afterSequenceNumber + 1
+ }).ConfigureAwait(false);
+ return [.. eventsResp.Events.OrderBy(e => e.Context.SequenceNumber)];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ async Task<(string? Version, bool IsConnected)> FetchVersionAsync()
+ {
+ try
+ {
+ var info = await services.Server.GetVersionInfo().ConfigureAwait(false);
+ return (info.Version, true);
+ }
+ catch
+ {
+ return (null, false);
+ }
+ }
+
+ async Task> FetchEventStoresAsync()
+ {
+ try { return [.. await services.EventStores.GetEventStores().ConfigureAwait(false)]; }
+ catch { return []; }
+ }
+
+ async Task> FetchObserversAsync(string eventStore, string ns)
+ {
+ try
+ {
+ return [.. await services.Observers.GetObservers(new AllObserversRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns
+ }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchFailedPartitionsAsync(string eventStore, string ns)
+ {
+ try
+ {
+ return [.. await services.FailedPartitions.GetFailedPartitions(new GetFailedPartitionsRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns
+ }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchJobsAsync(string eventStore, string ns)
+ {
+ try
+ {
+ return [.. (await services.Jobs.GetJobs(new GetJobsRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns
+ }).ConfigureAwait(false)) ?? []];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchRecommendationsAsync(string eventStore, string ns)
+ {
+ try
+ {
+ return [.. await services.Recommendations.GetRecommendations(new GetRecommendationsRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns
+ }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task FetchTailSequenceNumberAsync(string eventStore, string ns)
+ {
+ try
+ {
+ var tail = await services.EventSequences.GetTailSequenceNumber(new GetTailSequenceNumberRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }).ConfigureAwait(false);
+ return tail.SequenceNumber == ulong.MaxValue ? null : tail.SequenceNumber;
+ }
+ catch { return null; }
+ }
+
+ async Task> FetchEventTypesAsync(string eventStore)
+ {
+ try
+ {
+ return [.. await services.EventTypes.GetAllRegistrations(
+ new GetAllEventTypesRequest { EventStore = eventStore }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchProjectionDefinitionsAsync(string eventStore)
+ {
+ try
+ {
+ return [.. await services.Projections.GetAllDefinitions(
+ new GetAllDefinitionsRequest { EventStore = eventStore }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchProjectionDeclarationsAsync(string eventStore)
+ {
+ try
+ {
+ var declarations = await services.Projections.GetAllDeclarations(
+ new GetAllDeclarationsRequest { EventStore = eventStore }).ConfigureAwait(false);
+ return declarations.ToDictionary(d => d.Identifier, d => d.Declaration ?? string.Empty);
+ }
+ catch { return new Dictionary(); }
+ }
+
+ async Task> FetchRecentEventsAsync(string eventStore, string ns, ulong? tailSequenceNumber)
+ {
+ try
+ {
+ if (tailSequenceNumber is null or 0) return [];
+ var fromSeq = tailSequenceNumber.Value >= EventLogFetchWindow
+ ? tailSequenceNumber.Value - EventLogFetchWindow + 1
+ : 0;
+ var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber(
+ new GetFromEventSequenceNumberRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId,
+ FromEventSequenceNumber = fromSeq
+ }).ConfigureAwait(false);
+ return [.. eventsResp.Events.OrderByDescending(e => e.Context.SequenceNumber)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchReadModelDefinitionsAsync(string eventStore)
+ {
+ try
+ {
+ var defs = await services.ReadModels.GetDefinitions(
+ new GetDefinitionsRequest { EventStore = eventStore }).ConfigureAwait(false);
+ return [.. defs.ReadModels.Select(rm => new WorkbenchReadModel(
+ rm.ContainerName,
+ rm.DisplayName,
+ rm.Owner.ToString(),
+ !string.Equals(rm.Owner.ToString(), "Client", StringComparison.Ordinal),
+ rm.Source.ToString(),
+ rm.Type?.Identifier ?? string.Empty))];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchNamespacesAsync(string eventStore)
+ {
+ try
+ {
+ return [.. await services.Namespaces.GetNamespaces(
+ new GetNamespacesRequest { EventStore = eventStore }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchApplicationsAsync()
+ {
+ try { return [.. await services.Applications.GetAll().ConfigureAwait(false) ?? []]; }
+ catch { return []; }
+ }
+
+ async Task> FetchUsersAsync()
+ {
+ try { return [.. await services.Users.GetAll().ConfigureAwait(false) ?? []]; }
+ catch { return []; }
+ }
+
+ async Task> FetchIdentitiesAsync(string eventStore, string ns)
+ {
+ try
+ {
+ return [.. await services.Identities.GetIdentities(new GetIdentitiesRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns
+ }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task> FetchSubscriptionsAsync(string eventStore)
+ {
+ try
+ {
+ return [.. await services.EventStoreSubscriptions.GetSubscriptions(new GetEventStoreSubscriptionsRequest
+ {
+ TargetEventStore = eventStore
+ }).ConfigureAwait(false)];
+ }
+ catch { return []; }
+ }
+
+ async Task<(IReadOnlyList Instances, int TotalCount, string? Error)> FetchReadModelInstancesAsync(
+ string eventStore,
+ string ns,
+ string? containerName)
+ {
+ if (string.IsNullOrEmpty(containerName)) return ([], 0, null);
+ try
+ {
+ var instResp = await services.ReadModels.GetInstances(new GetInstancesRequest
+ {
+ EventStore = eventStore,
+ Namespace = ns,
+ ReadModel = containerName,
+ Page = 0,
+ PageSize = 20
+ }).ConfigureAwait(false);
+ var totalCount = (int)Math.Min(instResp.TotalCount, int.MaxValue);
+ var instances = (IReadOnlyList)[.. (instResp.Instances ?? [])
+ .Select(ReadModelJsonCleaner.CleanInstance)
+ .Where(o => o is not null)
+ .Select(o => JsonSerializer.Serialize(o, _instanceJsonOptions))];
+ return (instances, totalCount, null);
+ }
+ catch (Exception ex)
+ {
+ return ([], 0, ex.Message);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs
new file mode 100644
index 0000000..d2a9f86
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// User-facing hint strings displayed in the system status panels and help overlays.
+/// Centralised here so there are no magic strings scattered across the workbench.
+///
+static class WorkbenchHints
+{
+ /// Hint text shown in the system bottom panel at all times.
+ public const string BottomBar = "F Filter | ? Help | Ctrl+P Palette | Q Quit";
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs
deleted file mode 100644
index c13316f..0000000
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs
+++ /dev/null
@@ -1,265 +0,0 @@
-// Copyright (c) Cratis. All rights reserved.
-// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-
-using System.Text.Json;
-using Spectre.Console.Rendering;
-
-namespace Cratis.Cli.Commands.Chronicle.Workbench;
-
-public static partial class WorkbenchRenderer
-{
- static readonly JsonSerializerOptions _prettyJson = new() { WriteIndented = true };
-
- static Rows BuildObserverDetailPage(WorkbenchData data, string focusedId, int selectedIndex)
- {
- var obs = data.Observers.FirstOrDefault(o => o.Id == focusedId);
- if (obs is null)
- {
- return new Rows(new Panel(new Markup($"[{_dan}]Observer '{focusedId.EscapeMarkup()}' not found.[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand());
- }
-
- var lag = ComputeLag(obs, data.TailSequenceNumber);
- var lagText = lag switch { null => $"[{_mut}]—[/]", 0 => $"[{_suc}]caught up[/]", _ => $"[{_war}]{lag.Value:N0} events behind tail ({data.TailSequenceNumber?.ToString("N0") ?? "—"})[/]" };
-
- var infoPanel = new Panel(new Rows(
- new Markup($" [{_mut}]type[/] [bold]{obs.Type.ToString().EscapeMarkup()}[/] [{_mut}]owner[/] {obs.Owner.ToString().EscapeMarkup()} [{_mut}]sequence[/] [{_mut}]{obs.EventSequenceId.EscapeMarkup()}[/] [{_mut}]replayable[/] {(obs.IsReplayable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}"),
- new Markup($" [{_mut}]state[/] {StateIcon(obs.RunningState)} {StateName(obs.RunningState)} [{_mut}]subscribed[/] {(obs.IsSubscribed ? $"[{_suc}]yes[/]" : $"[{_dan}]no[/]")}"),
- new Markup($" [{_mut}]next seq[/] {FormatSeq(obs.NextEventSequenceNumber)} [{_mut}]last handled[/] {FormatSeq(obs.LastHandledEventSequenceNumber)} [{_mut}]lag[/] {lagText}")))
- .Header($"[{_acc}] {obs.Id.EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- var eventTypes = (obs.EventTypes ?? []).OrderBy(et => et.Id, StringComparer.OrdinalIgnoreCase).ToList();
- IRenderable eventTypesSection;
- if (eventTypes.Count == 0)
- {
- eventTypesSection = new Panel(new Markup($" [{_mut}]No subscribed event types.[/]"))
- .Header($"[{_acc}] Subscribed Event Types [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand();
- }
- else
- {
- var etEffective = Math.Min(selectedIndex, eventTypes.Count - 1);
- var etTable = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Generation[/]").Padding(1, 0).Width(12).NoWrap());
-
- var (winStart, winEnd) = ListWindow(eventTypes.Count, etEffective);
- if (winStart > 0)
- etTable.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var et = eventTypes[i];
- var isSel = i == etEffective;
- etTable.AddRow(
- new Markup(isSel ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSel ? $"[bold {_acc}]{et.Id.EscapeMarkup()}[/]" : et.Id.EscapeMarkup()),
- new Markup($"[{_mut}]{et.Generation}[/]"));
- }
-
- if (winEnd < eventTypes.Count - 1)
- etTable.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {eventTypes.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty));
-
- eventTypesSection = new Panel(etTable)
- .Header($"[{_acc}] Subscribed Event Types ({eventTypes.Count}) ↑↓ select [[ Enter ]] view schema [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
- }
-
- if (obs.Type.ToString().Contains("Projection", StringComparison.OrdinalIgnoreCase))
- {
- var projHint = new Panel(new Markup($" [{_acc}][[ P ]][/] [{_mut}]View this observer's projection definition[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand();
- return new Rows(infoPanel, eventTypesSection, projHint);
- }
-
- return new Rows(infoPanel, eventTypesSection);
- }
-
- static Rows BuildFailedPartitionDetailPage(WorkbenchData data, string focusedId, int scrollOffset)
- {
- var sep = focusedId.IndexOf('/');
- FailedPartition? fp = null;
- if (sep >= 0)
- {
- var obsId = focusedId[..sep];
- var partition = focusedId[(sep + 1)..];
- fp = data.FailedPartitions.FirstOrDefault(f => f.ObserverId == obsId && f.Partition == partition);
- }
-
- if (fp is null)
- {
- return new Rows(new Panel(new Markup($"[{_dan}]Failed partition not found.[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand());
- }
-
- var attempts = fp.Attempts.OrderByDescending(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue).ToList();
- var lastAttempt = attempts.FirstOrDefault();
- var msg = (lastAttempt?.Messages.FirstOrDefault() ?? "—").EscapeMarkup();
- var stackTrace = lastAttempt?.StackTrace ?? string.Empty;
-
- var header = new Panel(new Markup(
- $" [{_mut}]observer[/] [{_dan}]{fp.ObserverId.EscapeMarkup()}[/] [{_mut}]partition[/] {fp.Partition.EscapeMarkup()} [{_mut}]attempts[/] [{_dan}]{attempts.Count}[/]\n" +
- $" [{_mut}]last failed[/] [{_dan}]{(lastAttempt is not null ? lastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]"))
- .Header($"[{_dan}] Failure Detail [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand();
-
- var msgPanel = new Panel(new Markup($" [{_dan}]{msg}[/]"))
- .Header($"[{_dan}] Last Error [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand();
-
- var stackLines = (string.IsNullOrEmpty(stackTrace) ? ["(no stack trace)"] : stackTrace.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]").ToList())
- as IReadOnlyList;
- var stackPanel = ScrollableText("Stack Trace", stackLines, scrollOffset, OutputFormatter.Muted);
-
- return new Rows(header, msgPanel, stackPanel);
- }
-
- static Rows BuildEventDetailPage(WorkbenchData data, string focusedId, int scrollOffset)
- {
- AppendedEvent? evt = null;
- if (ulong.TryParse(focusedId, out var seq))
- evt = data.RecentEvents.FirstOrDefault(e => e.Context.SequenceNumber == seq);
-
- if (evt is null)
- {
- return new Rows(new Panel(new Markup($"[{_dan}]Event not found (seq# {focusedId.EscapeMarkup()}).[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand());
- }
-
- var ctx = evt.Context;
- var header = new Panel(new Rows(
- new Markup($" [{_mut}]seq#[/] [bold]{ctx.SequenceNumber:N0}[/] [{_mut}]eventType[/] [bold {_acc}]{(ctx.EventType?.Id ?? "—").EscapeMarkup()}[/] [{_mut}]+{ctx.EventType?.Generation}[/]"),
- new Markup($" [{_mut}]eventSourceId[/] {(ctx.EventSourceId ?? "—").EscapeMarkup()} [{_mut}]occurred[/] [{_mut}]{ctx.Occurred.ToString().EscapeMarkup()}[/]"),
- new Markup($" [{_mut}]correlationId[/] [{_mut}]{ctx.CorrelationId.ToString().EscapeMarkup()}[/]")))
- .Header($"[{_acc}] Event seq# {ctx.SequenceNumber:N0} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- List contentLines;
- if (string.IsNullOrWhiteSpace(evt.Content))
- {
- contentLines = [$" [{_mut}](no content)[/]"];
- }
- else
- {
- try
- {
- var pretty = JsonSerializer.Serialize(
- JsonSerializer.Deserialize(evt.Content),
- _prettyJson);
- contentLines = [.. pretty.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")];
- }
- catch
- {
- contentLines = [$" [{_mut}]{evt.Content.EscapeMarkup()}[/]"];
- }
- }
-
- var contentPanel = ScrollableText("Content", contentLines, scrollOffset, OutputFormatter.Muted);
- return new Rows(header, contentPanel);
- }
-
- static Rows BuildEventTypeDetailPage(WorkbenchData data, string focusedId, int scrollOffset)
- {
- var parts = focusedId.Split('+');
- var typeId = parts[0];
- var gen = parts.Length > 1 ? parts[1] : string.Empty;
- var reg = data.EventTypeRegistrations
- .FirstOrDefault(r => string.Equals(r.Type.Id, typeId, StringComparison.OrdinalIgnoreCase)
- && r.Type.Generation.ToString() == gen);
-
- if (reg is null)
- {
- return new Rows(new Panel(new Markup($"[{_dan}]Event type '{focusedId.EscapeMarkup()}' not found.[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand());
- }
-
- var header = new Panel(new Markup(
- $" [{_mut}]id[/] [bold]{reg.Type.Id.EscapeMarkup()}[/] [{_mut}]generation[/] {reg.Type.Generation} [{_mut}]owner[/] {reg.Owner.ToString().EscapeMarkup()} [{_mut}]source[/] {reg.Source.ToString().EscapeMarkup()} [{_mut}]tombstone[/] {reg.Type.Tombstone}"))
- .Header($"[{_acc}] {reg.Type.Id.EscapeMarkup()} +{reg.Type.Generation} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- List schemaLines;
- if (string.IsNullOrWhiteSpace(reg.Schema))
- {
- schemaLines = [$" [{_mut}](no schema)[/]"];
- }
- else
- {
- try
- {
- var pretty = JsonSerializer.Serialize(
- JsonSerializer.Deserialize(reg.Schema),
- _prettyJson);
- schemaLines = [.. pretty.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")];
- }
- catch
- {
- schemaLines = [.. reg.Schema.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")];
- }
- }
-
- var schemaPanel = ScrollableText("JSON Schema", schemaLines, scrollOffset, OutputFormatter.Muted);
- return new Rows(header, schemaPanel);
- }
-
- static Rows BuildReadModelDetailPage(WorkbenchData data, string focusedId, int scrollOffset)
- {
- var def = data.ReadModelDefinitions
- .FirstOrDefault(d => string.Equals(d.ContainerName, focusedId, StringComparison.OrdinalIgnoreCase));
-
- var header = new Panel(new Markup(
- $" [{_mut}]container[/] [bold]{focusedId.EscapeMarkup()}[/] [{_mut}]owner[/] {(def?.Owner ?? "—").EscapeMarkup()} [{_mut}]queryable[/] {(def?.IsQueryable == true ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]total[/] [bold]{data.ReadModelInstancesTotalCount:N0}[/]"))
- .Header($"[{_acc}] {focusedId.EscapeMarkup()} — Instances [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- IReadOnlyList lines;
- if (data.ReadModelInstancesError is not null)
- {
- lines = [$" [{_dan}]Error fetching instances: {data.ReadModelInstancesError.EscapeMarkup()}[/]"];
- }
- else if (data.ReadModelInstances.Count == 0)
- {
- lines = [$" [{_mut}](no instances — model may be client-owned or empty)[/]"];
- }
- else
- {
- var allLines = new List();
- for (var i = 0; i < data.ReadModelInstances.Count; i++)
- {
- if (i > 0) allLines.Add($" [{_mut}]─────────────────────────────────────────[/]");
- allLines.Add($" [{_acc}]Instance {i + 1}[/]");
- allLines.AddRange(data.ReadModelInstances[i].Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]"));
- }
- if (data.ReadModelInstancesTotalCount > data.ReadModelInstances.Count)
- allLines.Add($"\n [{_mut}]showing {data.ReadModelInstances.Count} of {data.ReadModelInstancesTotalCount:N0} total instances[/]");
- lines = allLines;
- }
-
- var instancesPanel = ScrollableText("Instances (↑↓ scroll)", lines, scrollOffset, OutputFormatter.Muted);
- return new Rows(header, instancesPanel);
- }
-
- static Rows BuildProjectionDetailPage(WorkbenchData data, string focusedId, int scrollOffset)
- {
- var def = data.ProjectionDefinitions
- .FirstOrDefault(d => string.Equals(d.Identifier, focusedId, StringComparison.OrdinalIgnoreCase));
-
- if (def is null)
- {
- return new Rows(new Panel(new Markup($"[{_dan}]Projection '{focusedId.EscapeMarkup()}' not found.[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand());
- }
-
- data.ProjectionDeclarations.TryGetValue(def.Identifier, out var declaration);
-
- var header = new Panel(new Markup(
- $" [{_mut}]readModel[/] {def.ReadModel.EscapeMarkup()} [{_mut}]active[/] {(def.IsActive ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]rewindable[/] {(def.IsRewindable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]autoMap[/] {def.AutoMap.ToString().EscapeMarkup()}"))
- .Header($"[{_acc}] {def.Identifier.EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- IReadOnlyList declLines = string.IsNullOrWhiteSpace(declaration)
- ? [$" [{_mut}](no declaration available)[/]"]
- : [.. declaration.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")];
-
- var declPanel = ScrollableText("Declaration", declLines, scrollOffset, OutputFormatter.Muted);
- return new Rows(header, declPanel);
- }
-}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs
deleted file mode 100644
index 8db9711..0000000
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs
+++ /dev/null
@@ -1,655 +0,0 @@
-// Copyright (c) Cratis. All rights reserved.
-// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-
-using Cratis.Chronicle.Contracts.Jobs;
-using Spectre.Console.Rendering;
-
-namespace Cratis.Cli.Commands.Chronicle.Workbench;
-
-public static partial class WorkbenchRenderer
-{
- const int EventLogPageSize = 50;
-
- static Rows BuildOverview(WorkbenchData data)
- {
- var topRow = new Table().HideHeaders().NoBorder().Expand()
- .AddColumn(new TableColumn(string.Empty))
- .AddColumn(new TableColumn(string.Empty));
- topRow.AddRow(BuildServerPanel(data), BuildObserverStatsPanel(data));
-
- var contextPanel = new Panel(new Rows(
- new Markup($" [{_mut}]event store[/] [bold {_acc}]{data.EventStore.EscapeMarkup()}[/] [{_mut}][[ E ]][/] change"),
- new Markup($" [{_mut}]namespace[/] [bold {_acc}]{data.Namespace.EscapeMarkup()}[/] [{_mut}][[ N ]][/] change")))
- .Header($"[{_acc}] Active Context [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand();
-
- var sections = new List { topRow, contextPanel };
- var attentionPanel = BuildAttentionPanel(data);
- if (attentionPanel is not null) sections.Add(attentionPanel);
- return new Rows(sections);
- }
-
- static Panel BuildServerPanel(WorkbenchData data)
- {
- var connLine = data.IsConnected
- ? $" [{_suc}]✓[/] connected [{_mut}]{(data.ServerVersion ?? "—").EscapeMarkup()}[/]"
- : $" [{_dan}]✗[/] [{_dan}]disconnected[/]";
- var storesLine = $" [{_mut}]·[/] [{_mut}]{data.EventStoreNames.Count} event store{(data.EventStoreNames.Count == 1 ? string.Empty : "s")}[/]";
- var tailLine = data.TailSequenceNumber.HasValue
- ? $" [{_mut}]·[/] [bold]{data.TailSequenceNumber.Value:N0}[/] [{_mut}]events in log[/]"
- : $" [{_mut}]· event log unavailable[/]";
-
- return new Panel(new Rows(new Markup(connLine), new Markup(storesLine), new Markup(tailLine)))
- .Header($"[{_acc}] Server [/]")
- .Border(BoxBorder.Rounded)
- .BorderColor(OutputFormatter.Muted);
- }
-
- static Panel BuildObserverStatsPanel(WorkbenchData data)
- {
- var total = data.Observers.Count;
- var line1 = $" [{_suc}]●[/] [bold {_suc}]{data.ActiveObservers,3}[/] [{_suc}]Active[/] [{_war}]▲[/] [bold {_war}]{data.ReplayingObservers,3}[/] [{_war}]Replaying[/]";
- var line2 = $" [{_mut}]○[/] [bold {_mut}]{data.SuspendedObservers,3}[/] [{_mut}]Suspended[/] [{_mut}]⊘[/] [bold {_mut}]{data.DisconnectedObservers,3}[/] [{_mut}]Disconnected[/]";
- var failureLine = data.FailedPartitions.Count > 0
- ? $" [{_dan}]✗[/] [{_dan}]{data.FailedPartitions.Count} failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")}[/]"
- : $" [{_suc}]✓[/] [{_suc}]no failed partitions[/]";
-
- return new Panel(new Rows(new Markup(line1), new Markup(line2), new Markup(failureLine)))
- .Header($"[{_acc}] Observers ({total}) [/]")
- .Border(BoxBorder.Rounded)
- .BorderColor(OutputFormatter.Muted);
- }
-
- static Panel? BuildAttentionPanel(WorkbenchData data)
- {
- var items = new List();
- foreach (var fp in data.FailedPartitions.Take(5))
- {
- var lastAttempt = fp.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue);
- var msg = (lastAttempt?.Messages.FirstOrDefault() ?? "unknown error").EscapeMarkup();
- if (msg.Length > 80) msg = msg[..80] + "…";
- var attempts = fp.Attempts.Count();
- items.Add($" [{_dan}]✗[/] {fp.ObserverId.EscapeMarkup()} [{_mut}]on[/] {fp.Partition.EscapeMarkup()} [{_dan}]{msg}[/] [{_mut}]({attempts} attempt{(attempts == 1 ? string.Empty : "s")})[/]");
- }
-
- foreach (var rec in data.Recommendations.Take(3))
- {
- var desc = (rec.Description ?? string.Empty).EscapeMarkup();
- if (desc.Length > 80) desc = desc[..80] + "…";
- items.Add($" [{_war}]▲[/] [{_war}]{(rec.Name ?? string.Empty).EscapeMarkup()}[/] [{_mut}]{desc}[/]");
- }
-
- if (items.Count == 0) return null;
-
- var borderColor = data.FailedPartitions.Count > 0 ? OutputFormatter.Danger : OutputFormatter.Warning;
- var headerColor = data.FailedPartitions.Count > 0 ? _dan : _war;
- return new Panel(new Rows(items.ConvertAll(i => (IRenderable)new Markup(i))))
- .Header($"[{headerColor}] Attention Needed ({items.Count}) [/]")
- .Border(BoxBorder.Rounded)
- .BorderColor(borderColor);
- }
-
- static Rows BuildObserversView(WorkbenchData data, int selectedIndex, string filterText)
- {
- var allObservers = data.Observers.OrderBy(o => StateOrder(o.RunningState)).ThenBy(o => o.Id).ToList();
- var observers = string.IsNullOrEmpty(filterText)
- ? allObservers
- : [.. allObservers.Where(o => o.Id.Contains(filterText, StringComparison.OrdinalIgnoreCase))];
- var effectiveIndex = observers.Count > 0 ? Math.Min(selectedIndex, observers.Count - 1) : -1;
-
- var table = new Table()
- .Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Observer[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0).Width(20).NoWrap())
- .AddColumn(new TableColumn("[bold]State[/]").Padding(1, 0).Width(18).NoWrap());
-
- AddListRows(table, observers, effectiveIndex, data.TailSequenceNumber, 4);
-
- var header = string.IsNullOrEmpty(filterText)
- ? $"[{_acc}] Observers ({data.Observers.Count}) [/]"
- : $"[{_acc}] Observers ({observers.Count}/{data.Observers.Count}) [/]";
- var sections = new List
- {
- new Panel(table).Header(header).BorderColor(OutputFormatter.Accent).NoBorder()
- };
-
- if (effectiveIndex >= 0 && observers.Count > 0)
- sections.Add(BuildObserverMiniDetail(observers[effectiveIndex], data.TailSequenceNumber));
-
- return new Rows(sections);
- }
-
- static void AddListRows(Table table, List observers, int effectiveIndex, ulong? tail, int colCount)
- {
- if (observers.Count == 0)
- {
- var empty = Enumerable.Range(0, colCount).Select(i => i == 1 ? (IRenderable)new Markup($"[{_mut}](none)[/]") : new Markup(string.Empty)).ToArray();
- table.AddRow(empty);
- return;
- }
-
- var (winStart, winEnd) = ListWindow(observers.Count, effectiveIndex < 0 ? 0 : effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty));
-
- for (var i = winStart; i <= winEnd; i++)
- {
- var obs = observers[i];
- var isSelected = i == effectiveIndex;
- var pointer = isSelected ? $"[bold {_acc}]▶[/]" : string.Empty;
- table.AddRow(
- new Markup(pointer),
- new Markup(isSelected ? $"[bold {_acc}]{obs.Id.EscapeMarkup()}[/]" : obs.Id.EscapeMarkup()),
- new Markup($"[{_mut}]{obs.Type.ToString().EscapeMarkup()}[/]"),
- new Markup($"{StateIcon(obs.RunningState)} {StateName(obs.RunningState)}"));
- }
-
- if (winEnd < observers.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {observers.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty));
- }
-
- static Panel BuildObserverMiniDetail(ObserverInformation obs, ulong? tail)
- {
- var line1 = $" [{_mut}]type[/] [bold]{obs.Type.ToString().EscapeMarkup()}[/] [{_mut}]state[/] {StateIcon(obs.RunningState)} {StateName(obs.RunningState)}";
- var line2 = $" [{_mut}]next[/] {FormatSeq(obs.NextEventSequenceNumber)} [{_mut}]handled[/] {FormatSeq(obs.LastHandledEventSequenceNumber)}";
- var line3 = $"\n [{_acc}][[ Enter ]][/] Full detail [{_acc}][[ R ]][/] Replay";
- return new Panel(new Rows(new Markup(line1), new Markup(line2), new Markup(line3)))
- .Header($"[{_acc}] {obs.Id.EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
- }
-
- static Rows BuildFailedView(WorkbenchData data, int selectedIndex)
- {
- if (data.FailedPartitions.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_suc}]✓ No failed partitions.[/]\n"))
- .Header($"[{_acc}] Failures [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Success));
- }
-
- var fps = data.FailedPartitions.OrderByDescending(fp => fp.Attempts.Count()).ToList();
- var effectiveIndex = Math.Min(selectedIndex, fps.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Observer[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Partition[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Attempts[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Last Failed[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(fps.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var fp = fps[i];
- var isSelected = i == effectiveIndex;
- var lastAttempt = fp.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue);
- var obsId = fp.ObserverId.EscapeMarkup();
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{obsId}[/]" : $"[{_dan}]{obsId}[/]"),
- new Markup(fp.Partition.EscapeMarkup()),
- new Markup(fp.Attempts.Count().ToString()),
- new Markup($"[{_dan}]{(lastAttempt is not null ? lastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]"));
- }
-
- if (winEnd < fps.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {fps.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
-
- var selected = fps[effectiveIndex];
- var selLastAttempt = selected.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue);
- var msg = (selLastAttempt?.Messages.FirstOrDefault() ?? "—").EscapeMarkup();
- if (msg.Length > 80) msg = msg[..80] + "…";
- var miniDetail = new Panel(new Rows(
- new Markup($" [{_mut}]partition[/] {selected.Partition.EscapeMarkup()} [{_mut}]attempts[/] [{_dan}]{selected.Attempts.Count()}[/] [{_mut}]last failed[/] [{_dan}]{(selLastAttempt is not null ? selLastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]"),
- new Markup($" [{_dan}]{msg}[/]"),
- new Markup($"\n [{_acc}][[ Enter ]][/] Full detail [{_acc}][[ T ]][/] Retry [{_acc}][[ P ]][/] Replay partition")))
- .Header($"[{_dan}] Failure Detail [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand();
-
- return new Rows(
- new Panel(table).Header($"[{_dan}] Failures ({fps.Count}) [/]").BorderColor(OutputFormatter.Danger).NoBorder(),
- miniDetail);
- }
-
- static Rows BuildJobsView(WorkbenchData data, int selectedIndex)
- {
- if (data.Jobs.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No background jobs.[/]\n"))
- .Header($"[{_acc}] Jobs [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var jobs = data.Jobs.OrderBy(j => j.Status.ToString()).ToList();
- var effectiveIndex = Math.Min(selectedIndex, jobs.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Status[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Progress[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Created[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(jobs.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var job = jobs[i];
- var isSelected = i == effectiveIndex;
- var statusStr = job.Status.ToString();
- string statusColor;
- if (statusStr.Contains("Running", StringComparison.OrdinalIgnoreCase)) statusColor = _suc;
- else if (statusStr.Contains("Failed", StringComparison.OrdinalIgnoreCase)) statusColor = _dan;
- else statusColor = _mut;
- string progressCell;
- if (job.Progress?.TotalSteps > 0)
- {
- var bar = ProgressBar(job.Progress.SuccessfulSteps, job.Progress.TotalSteps);
- var pct = (int)Math.Min(100, (long)job.Progress.SuccessfulSteps * 100 / job.Progress.TotalSteps);
- progressCell = $"{bar} {job.Progress.SuccessfulSteps:N0}/{job.Progress.TotalSteps:N0} [{_mut}]{pct}%[/]";
- }
- else
- {
- progressCell = $"[{_mut}]{(job.Progress?.Message ?? "—").EscapeMarkup()}[/]";
- }
-
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{(job.Type ?? "—").EscapeMarkup()}[/]" : (job.Type ?? "—").EscapeMarkup()),
- new Markup($"[{statusColor}]{statusStr.EscapeMarkup()}[/]"),
- new Markup(progressCell),
- new Markup(job.Created.ToString().EscapeMarkup()));
- }
-
- if (winEnd < jobs.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {jobs.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
-
- return new Rows(new Panel(table).Header($"[{_acc}] Jobs ({jobs.Count}) [/]").BorderColor(OutputFormatter.Accent).NoBorder());
- }
-
- static Rows BuildRecommendationsView(WorkbenchData data, int selectedIndex)
- {
- if (data.Recommendations.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_suc}]✓ No pending recommendations.[/]\n"))
- .Header($"[{_acc}] Recommendations [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Success));
- }
-
- var recs = data.Recommendations.ToList();
- var effectiveIndex = Math.Min(selectedIndex, recs.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Warning).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Recommendation[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Occurred[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(recs.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var rec = recs[i];
- var isSelected = i == effectiveIndex;
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{(rec.Name ?? "—").EscapeMarkup()}[/]" : $"[{_war}]{(rec.Name ?? "—").EscapeMarkup()}[/]"),
- new Markup($"[{_mut}]{(rec.Type ?? "—").EscapeMarkup()}[/]"),
- new Markup($"[{_mut}]{rec.Occurred.ToString().EscapeMarkup()}[/]"));
- }
-
- if (winEnd < recs.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {recs.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty));
-
- var selRec = recs[effectiveIndex];
- var desc = (selRec.Description ?? string.Empty).EscapeMarkup();
- if (desc.Length > 100) desc = desc[..100] + "…";
- var detail = new Panel(new Rows(
- new Markup($" [{_mut}]type[/] [{_war}]{(selRec.Type ?? "—").EscapeMarkup()}[/] [{_mut}]occurred[/] [{_mut}]{selRec.Occurred.ToString().EscapeMarkup()}[/]"),
- new Markup($" [{_mut}]{desc}[/]"),
- new Markup($"\n [{_acc}][[ A ]][/] Apply [{_acc}][[ I ]][/] Ignore")))
- .Header($"[{_war}] {(selRec.Name ?? "—").EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Warning).Expand();
-
- return new Rows(new Panel(table).Header($"[{_war}] Recommendations ({recs.Count}) [/]").BorderColor(OutputFormatter.Warning).NoBorder(), detail);
- }
-
- static Rows BuildEventLogView(WorkbenchData data, int selectedIndex, string filterText, bool ascending, int page)
- {
- if (data.RecentEvents.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No events in the event log yet.[/]\n"))
- .Header($"[{_acc}] Event Log [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- // Sort the full event window, then filter, then page.
- var sortedEvents = ascending
- ? [.. data.RecentEvents.OrderBy(e => e.Context.SequenceNumber)]
- : data.RecentEvents.ToList();
-
- var filteredEvents = string.IsNullOrEmpty(filterText)
- ? sortedEvents
- : [.. sortedEvents.Where(e => (e.Context.EventType?.Id ?? string.Empty).Contains(filterText, StringComparison.OrdinalIgnoreCase))];
-
- if (filteredEvents.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No events matching '{filterText.EscapeMarkup()}'.[/]\n"))
- .Header($"[{_acc}] Event Log (0/{sortedEvents.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var totalPages = Math.Max(1, (filteredEvents.Count + EventLogPageSize - 1) / EventLogPageSize);
- var effectivePage = Math.Min(page, totalPages - 1);
- var pageStart = effectivePage * EventLogPageSize;
- var pageEvents = filteredEvents.Skip(pageStart).Take(EventLogPageSize).ToList();
- var effectiveIndex = Math.Min(selectedIndex, Math.Max(0, pageEvents.Count - 1));
-
- var sortIndicator = ascending ? $"[{_mut}]↑ oldest first[/]" : $"[{_mut}]↓ newest first[/]";
- var pageIndicator = totalPages > 1
- ? $" [{_mut}]page {effectivePage + 1}/{totalPages}[/]"
- : string.Empty;
-
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Seq#[/]").Padding(1, 0).RightAligned())
- .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]EventSourceId[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Occurred[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(pageEvents.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var evt = pageEvents[i];
- var ctx = evt.Context;
- var isSelected = i == effectiveIndex;
- var typeId = ctx.EventType?.Id ?? "—";
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup($"[{_mut}]{ctx.SequenceNumber:N0}[/]"),
- new Markup(isSelected ? $"[bold {_acc}]{typeId.EscapeMarkup()}[/]" : typeId.EscapeMarkup()),
- new Markup((ctx.EventSourceId ?? string.Empty).EscapeMarkup()),
- new Markup($"[{_mut}]{ctx.Occurred.ToString().EscapeMarkup()}[/]"));
- }
-
- if (winEnd < pageEvents.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup(string.Empty), new Markup($"[{_mut}]↓ {pageEvents.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty));
-
- var selEvt = pageEvents[effectiveIndex];
- var selCtx = selEvt.Context;
- var miniDetail = new Panel(new Markup(
- $" [{_mut}]seq#[/] [bold]{selCtx.SequenceNumber:N0}[/] [{_mut}]eventSource[/] {(selCtx.EventSourceId ?? "—").EscapeMarkup()} [{_mut}]occurred[/] [{_mut}]{selCtx.Occurred.ToString().EscapeMarkup()}[/]\n" +
- $"\n [{_acc}][[ Enter ]][/] Full event [{_acc}][[ T ]][/] View event type"))
- .Header($"[{_acc}] {(selCtx.EventType?.Id ?? "—").EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- var filterSuffix = string.IsNullOrEmpty(filterText)
- ? string.Empty
- : $" ({filteredEvents.Count}/{sortedEvents.Count} matching)";
- var logHeader = $"[{_acc}] Event Log{filterSuffix} {sortIndicator}{pageIndicator} [[PgDn/PgUp]] page [/]";
- return new Rows(
- new Panel(table).Header(logHeader).BorderColor(OutputFormatter.Accent).NoBorder(),
- miniDetail);
- }
-
- static Rows BuildEventTypesView(WorkbenchData data, int selectedIndex, string filterText)
- {
- if (data.EventTypeRegistrations.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No event types registered.[/]\n"))
- .Header($"[{_acc}] Event Types [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var allTypes = data.EventTypeRegistrations.OrderBy(r => r.Type.Id, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Type.Generation).ToList();
- var types = string.IsNullOrEmpty(filterText)
- ? allTypes
- : [.. allTypes.Where(r => r.Type.Id.Contains(filterText, StringComparison.OrdinalIgnoreCase))];
-
- if (types.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No event types matching '{filterText.EscapeMarkup()}'.[/]\n"))
- .Header($"[{_acc}] Event Types (0/{allTypes.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var effectiveIndex = Math.Min(selectedIndex, types.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Gen[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Owner[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Source[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(types.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var reg = types[i];
- var isSelected = i == effectiveIndex;
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{reg.Type.Id.EscapeMarkup()}[/]" : reg.Type.Id.EscapeMarkup()),
- new Markup($"[{_mut}]{reg.Type.Generation}[/]"),
- new Markup($"[{_mut}]{reg.Owner.ToString().EscapeMarkup()}[/]"),
- new Markup($"[{_mut}]{reg.Source.ToString().EscapeMarkup()}[/]"));
- }
-
- if (winEnd < types.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {types.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
-
- var selType = types[effectiveIndex];
- var miniDetail = new Panel(new Markup(
- $" [{_mut}]owner[/] {selType.Owner.ToString().EscapeMarkup()} [{_mut}]source[/] {selType.Source.ToString().EscapeMarkup()} [{_mut}]tombstone[/] {selType.Type.Tombstone}\n" +
- $"\n [{_acc}][[ Enter ]][/] View schema"))
- .Header($"[{_acc}] {selType.Type.Id.EscapeMarkup()} +{selType.Type.Generation} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- var typesHeader = string.IsNullOrEmpty(filterText)
- ? $"[{_acc}] Event Types ({allTypes.Count}) [/]"
- : $"[{_acc}] Event Types ({types.Count}/{allTypes.Count}) [/]";
- return new Rows(
- new Panel(table).Header(typesHeader).BorderColor(OutputFormatter.Accent).NoBorder(),
- miniDetail);
- }
-
- static Rows BuildProjectionsView(WorkbenchData data, int selectedIndex, string filterText)
- {
- if (data.ProjectionDefinitions.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No projections registered.[/]\n"))
- .Header($"[{_acc}] Projections [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var allProjections = data.ProjectionDefinitions.OrderBy(d => d.Identifier, StringComparer.OrdinalIgnoreCase).ToList();
- var projections = string.IsNullOrEmpty(filterText)
- ? allProjections
- : [.. allProjections.Where(d => d.Identifier.Contains(filterText, StringComparison.OrdinalIgnoreCase))];
-
- if (projections.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No projections matching '{filterText.EscapeMarkup()}'.[/]\n"))
- .Header($"[{_acc}] Projections (0/{allProjections.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var effectiveIndex = Math.Min(selectedIndex, projections.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Identifier[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]ReadModel[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Active[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Rewindable[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(projections.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var def = projections[i];
- var isSelected = i == effectiveIndex;
- var activeIcon = def.IsActive ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]";
- var rewindIcon = def.IsRewindable ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]";
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{def.Identifier.EscapeMarkup()}[/]" : def.Identifier.EscapeMarkup()),
- new Markup($"[{_mut}]{def.ReadModel.EscapeMarkup()}[/]"),
- new Markup(activeIcon),
- new Markup(rewindIcon));
- }
-
- if (winEnd < projections.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {projections.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
-
- var selDef = projections[effectiveIndex];
- var miniDetail = new Panel(new Markup(
- $" [{_mut}]readModel[/] {selDef.ReadModel.EscapeMarkup()} [{_mut}]active[/] {(selDef.IsActive ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]rewindable[/] {(selDef.IsRewindable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}\n" +
- $"\n [{_acc}][[ Enter ]][/] View declaration"))
- .Header($"[{_acc}] {selDef.Identifier.EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- var projHeader = string.IsNullOrEmpty(filterText)
- ? $"[{_acc}] Projections ({allProjections.Count}) [/]"
- : $"[{_acc}] Projections ({projections.Count}/{allProjections.Count}) [/]";
- return new Rows(
- new Panel(table).Header(projHeader).BorderColor(OutputFormatter.Accent).NoBorder(),
- miniDetail);
- }
-
- static Rows BuildReadModelsView(WorkbenchData data, int selectedIndex, string filterText)
- {
- if (data.ReadModelDefinitions.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No read models registered.[/]\n"))
- .Header($"[{_acc}] Read Models [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var allModels = data.ReadModelDefinitions.OrderBy(d => d.ContainerName, StringComparer.OrdinalIgnoreCase).ToList();
- var models = string.IsNullOrEmpty(filterText)
- ? allModels
- : [.. allModels.Where(d => d.ContainerName.Contains(filterText, StringComparison.OrdinalIgnoreCase)
- || d.DisplayName.Contains(filterText, StringComparison.OrdinalIgnoreCase))];
-
- if (models.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No read models matching '{filterText.EscapeMarkup()}'.[/]\n"))
- .Header($"[{_acc}] Read Models (0/{allModels.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var effectiveIndex = Math.Min(selectedIndex, models.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Container[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Display Name[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Owner[/]").Padding(1, 0))
- .AddColumn(new TableColumn("[bold]Queryable[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(models.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
- for (var i = winStart; i <= winEnd; i++)
- {
- var rm = models[i];
- var isSelected = i == effectiveIndex;
- var queryIcon = rm.IsQueryable ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]";
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{rm.ContainerName.EscapeMarkup()}[/]" : rm.ContainerName.EscapeMarkup()),
- new Markup($"[{_mut}]{rm.DisplayName.EscapeMarkup()}[/]"),
- new Markup($"[{_mut}]{rm.Owner.EscapeMarkup()}[/]"),
- new Markup(queryIcon));
- }
-
- if (winEnd < models.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {models.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty));
-
- var selModel = models[effectiveIndex];
- var miniDetail = new Panel(new Markup(
- $" [{_mut}]owner[/] {selModel.Owner.EscapeMarkup()} [{_mut}]source[/] {selModel.Source.EscapeMarkup()} [{_mut}]queryable[/] {(selModel.IsQueryable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}\n" +
- $" [{_mut}]identifier[/] {selModel.Identifier.EscapeMarkup()}\n" +
- (selModel.IsQueryable ? $"\n [{_acc}][[ Enter ]][/] View instances" : $"\n [{_mut}](client-owned — instances are not stored on the server)[/]")))
- .Header($"[{_acc}] {selModel.ContainerName.EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand();
-
- var rmHeader = string.IsNullOrEmpty(filterText)
- ? $"[{_acc}] Read Models ({allModels.Count}) [/]"
- : $"[{_acc}] Read Models ({models.Count}/{allModels.Count}) [/]";
- return new Rows(
- new Panel(table).Header(rmHeader).BorderColor(OutputFormatter.Accent).NoBorder(),
- miniDetail);
- }
-
- static Rows BuildEventStoresView(WorkbenchData data, int selectedIndex)
- {
- var stores = data.EventStoreNames.Order(StringComparer.OrdinalIgnoreCase).ToList();
- if (stores.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No event stores found.[/]\n"))
- .Header($"[{_acc}] Event Stores [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var effectiveIndex = Math.Min(selectedIndex, stores.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Event Store[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(stores.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"));
- for (var i = winStart; i <= winEnd; i++)
- {
- var store = stores[i];
- var isSelected = i == effectiveIndex;
- var isActive = string.Equals(store, data.EventStore, StringComparison.Ordinal);
- var label = isActive ? $"[bold]{store.EscapeMarkup()}[/] [{_suc}]← active[/]" : store.EscapeMarkup();
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{store.EscapeMarkup()}[/]" : label));
- }
-
- if (winEnd < stores.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {stores.Count - 1 - winEnd} more below[/]"));
-
- var hint = new Panel(new Markup($" [{_mut}]Press[/] [{_acc}][[ Enter ]][/] [{_mut}]to switch to the selected event store.[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted);
-
- return new Rows(
- new Panel(table).Header($"[{_acc}] Event Stores ({stores.Count}) active: {data.EventStore.EscapeMarkup()} [/]").BorderColor(OutputFormatter.Accent).NoBorder(),
- hint);
- }
-
- static Rows BuildNamespacesView(WorkbenchData data, int selectedIndex)
- {
- if (data.NamespaceNames.Count == 0)
- {
- return new Rows(new Panel(new Markup($"\n [{_mut}]No namespaces found (or data not yet loaded).[/]\n"))
- .Header($"[{_acc}] Namespaces [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted));
- }
-
- var nsList = data.NamespaceNames.Order(StringComparer.OrdinalIgnoreCase).ToList();
- var effectiveIndex = Math.Min(selectedIndex, nsList.Count - 1);
- var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand()
- .AddColumn(new TableColumn(string.Empty).Width(2))
- .AddColumn(new TableColumn("[bold]Namespace[/]").Padding(1, 0));
-
- var (winStart, winEnd) = ListWindow(nsList.Count, effectiveIndex);
- if (winStart > 0)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"));
- for (var i = winStart; i <= winEnd; i++)
- {
- var ns = nsList[i];
- var isSelected = i == effectiveIndex;
- var isActive = string.Equals(ns, data.Namespace, StringComparison.Ordinal);
- var label = isActive ? $"[bold]{ns.EscapeMarkup()}[/] [{_suc}]← active[/]" : ns.EscapeMarkup();
- table.AddRow(
- new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty),
- new Markup(isSelected ? $"[bold {_acc}]{ns.EscapeMarkup()}[/]" : label));
- }
-
- if (winEnd < nsList.Count - 1)
- table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {nsList.Count - 1 - winEnd} more below[/]"));
-
- var hint = new Panel(new Markup($" [{_mut}]Press[/] [{_acc}][[ Enter ]][/] [{_mut}]to switch to the selected namespace.[/]"))
- .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted);
-
- return new Rows(
- new Panel(table).Header($"[{_acc}] Namespaces ({nsList.Count}) active: {data.Namespace.EscapeMarkup()} [/]").BorderColor(OutputFormatter.Accent).NoBorder(),
- hint);
- }
-}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs
deleted file mode 100644
index 3fa62a6..0000000
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs
+++ /dev/null
@@ -1,316 +0,0 @@
-// Copyright (c) Cratis. All rights reserved.
-// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-
-using Cratis.Chronicle.Contracts.Jobs;
-using Spectre.Console.Rendering;
-
-namespace Cratis.Cli.Commands.Chronicle.Workbench;
-
-///
-/// Builds Spectre.Console renderables for the Chronicle workbench dashboard.
-///
-public static partial class WorkbenchRenderer
-{
- /// Maximum number of list rows shown before the sliding window kicks in.
- const int MaxListRows = 10;
-
- /// Height of the scrollable text viewport in detail views (lines).
- const int ScrollViewport = 12;
-
- static readonly string _acc = OutputFormatter.Accent.ToMarkup();
- static readonly string _mut = OutputFormatter.Muted.ToMarkup();
- static readonly string _suc = OutputFormatter.Success.ToMarkup();
- static readonly string _war = OutputFormatter.Warning.ToMarkup();
- static readonly string _dan = OutputFormatter.Danger.ToMarkup();
-
- static readonly (WorkbenchView V, string Key, string Label)[] _views =
- [
- (WorkbenchView.Overview, "1", "Overview"),
- (WorkbenchView.Observers, "2", "Observers"),
- (WorkbenchView.FailedPartitions, "3", "Failures"),
- (WorkbenchView.Jobs, "4", "Jobs"),
- (WorkbenchView.Recommendations, "5", "Recommendations"),
- (WorkbenchView.EventLog, "6", "Event Log"),
- (WorkbenchView.EventTypes, "7", "Event Types"),
- (WorkbenchView.Projections, "8", "Projections"),
- (WorkbenchView.ReadModels, "9", "Read Models"),
- (WorkbenchView.EventStores, "0", "Event Stores"),
- (WorkbenchView.Namespaces, string.Empty, "Namespaces")
- ];
-
- ///
- /// Builds the full dashboard renderable for the given data and render state.
- ///
- /// The workbench snapshot to render.
- /// All render-relevant state for this frame.
- /// The renderable dashboard.
- public static Rows Build(WorkbenchData data, WorkbenchRenderState state) =>
- new(BuildHeader(data, state), BuildBody(data, state), BuildFooter(data, state));
-
- static Panel BuildHeader(WorkbenchData data, WorkbenchRenderState state)
- {
- var dot = data.IsConnected ? $"[{_suc}]●[/]" : $"[{_dan}]●[/]";
- var cs = DisplayConnectionString(data.ConnectionString);
- var store = $"[bold]{data.EventStore.EscapeMarkup()}[/][{_mut}] / {data.Namespace.EscapeMarkup()}[/]";
- var titleLine = $" [{_acc}]◆[/] [bold]CHRONICLE WORKBENCH[/] [{_mut}]{cs.EscapeMarkup()}[/] {dot} {store}";
-
- // Second line: breadcrumb when in a detail view, otherwise nav tabs.
- string secondLine;
- var breadcrumb = state.Breadcrumb;
- if (breadcrumb is { Count: > 0 })
- {
- var path = string.Join($" [{_mut}]›[/] ", breadcrumb.Select(s => s.EscapeMarkup()));
- secondLine = $" [{_mut}]{path}[/] [{_mut}]↻ {state.Interval}s[/]";
- }
- else
- {
- var failuresAlert = data.FailedPartitions.Count > 0;
- var recsAlert = data.Recommendations.Count > 0;
- var tabs = string.Join(" ", _views.Select(v =>
- {
- var isActive = v.V == state.View;
- var keyPart = string.IsNullOrEmpty(v.Key) ? string.Empty : $"[[ {v.Key} ]] ";
- var alert = (v.V == WorkbenchView.FailedPartitions && failuresAlert)
- || (v.V == WorkbenchView.Recommendations && recsAlert);
- if (isActive)
- return $"[bold {_acc}]{keyPart}{v.Label.EscapeMarkup()}[/]";
- if (alert)
- return $"[bold {_war}]{keyPart}{v.Label.EscapeMarkup()}[/]";
- return string.IsNullOrEmpty(v.Key)
- ? $"[{_mut}]{v.Label.EscapeMarkup()}[/]"
- : $"[{_mut}]{keyPart}[/]{v.Label.EscapeMarkup()}";
- }));
- var refreshIndicator = $"[{_mut}]↻ {state.Interval}s[/]";
- secondLine = $" {tabs} {refreshIndicator}";
- }
-
- return new Panel(new Rows(new Markup(titleLine), new Markup(secondLine)))
- .Border(BoxBorder.Heavy)
- .BorderColor(OutputFormatter.Accent)
- .Padding(0, 0);
- }
-
- static Rows BuildFooter(WorkbenchData data, WorkbenchRenderState state)
- {
- var sep = new Rule().RuleStyle(new Style(foreground: OutputFormatter.Muted));
-
- Markup statusLine;
- switch (state.ActionState)
- {
- case WorkbenchActionState.AwaitingConfirmation:
- var desc = (state.PendingActionDescription ?? string.Empty).EscapeMarkup();
- statusLine = new Markup($" [{_war}]⚡ {desc}?[/] [{_war}][[ Y ]][/] Confirm [{_mut}][[ N ]][/] Cancel");
- break;
-
- case WorkbenchActionState.Executing:
- var execDesc = (state.PendingActionDescription ?? "Working").EscapeMarkup();
- statusLine = new Markup($" [{_acc}]⟳ {execDesc}…[/]");
- break;
-
- case WorkbenchActionState.Completed:
- var result = (state.ActionResult ?? string.Empty).EscapeMarkup();
- var resColor = state.IsActionError ? _dan : _suc;
- var resIcon = state.IsActionError ? "✗" : "✓";
- statusLine = new Markup($" [{resColor}]{resIcon} {result}[/] [{_mut}](press any key to dismiss)[/]");
- break;
-
- default:
- var isDetail = (int)state.View >= 100;
- var connected = data.IsConnected ? $"[{_suc}]✓ connected[/]" : $"[{_dan}]✗ disconnected[/]";
- var seqTail = data.TailSequenceNumber.HasValue
- ? $"seq# [bold]{data.TailSequenceNumber.Value:N0}[/]"
- : $"[{_mut}]seq# —[/]";
-
- if (isDetail)
- {
- var actions = GetActionHints(state.View);
- var scrollHint = $"[{_mut}]↑↓ scroll [/]";
- statusLine = new Markup($" {connected} {seqTail} {scrollHint}{actions}[{_mut}][[ Esc ]][/] Back");
- }
- else
- {
- var navHint = HasNavigation(state.View) ? $"[{_mut}]↑↓ select [/]" : string.Empty;
- var enterHint = HasDrillDown(state.View) && !state.FilterInputMode ? $"[{_mut}][[ Enter ]][/] detail " : string.Empty;
- var actions = !state.FilterInputMode ? GetActionHints(state.View) : string.Empty;
- string filterIndicator;
- if (state.FilterInputMode)
- filterIndicator = $"[{_acc}]/ {state.FilterText.EscapeMarkup()}█[/] [{_mut}][[ Enter ]] confirm [[ Esc ]] cancel [/]";
- else if (!string.IsNullOrEmpty(state.FilterText))
- filterIndicator = $"[{_acc}]/{state.FilterText.EscapeMarkup()}[/] [{_mut}][[ Esc ]] clear [/]";
- else
- filterIndicator = string.Empty;
- var filterHint = !state.FilterInputMode && IsFilterableView(state.View) && string.IsNullOrEmpty(state.FilterText)
- ? $"[{_mut}][[ / ]] filter [/]"
- : string.Empty;
- var quit = $"[{_mut}]← → views [[ +/- ]] interval ({state.Interval}s) [[ Q ]] Quit[/]";
- statusLine = new Markup($" {connected} {seqTail} {navHint}{enterHint}{filterHint}{actions}{filterIndicator}{quit}");
- }
-
- break;
- }
-
- return new Rows(sep, statusLine);
- }
-
- static IRenderable BuildBody(WorkbenchData data, WorkbenchRenderState state) =>
- state.View switch
- {
- WorkbenchView.Overview => BuildOverview(data),
- WorkbenchView.Observers => BuildObserversView(data, state.SelectedIndex, state.FilterText),
- WorkbenchView.FailedPartitions => BuildFailedView(data, state.SelectedIndex),
- WorkbenchView.Jobs => BuildJobsView(data, state.SelectedIndex),
- WorkbenchView.Recommendations => BuildRecommendationsView(data, state.SelectedIndex),
- WorkbenchView.EventLog => BuildEventLogView(data, state.SelectedIndex, state.FilterText, state.EventLogAscending, state.EventLogPage),
- WorkbenchView.EventTypes => BuildEventTypesView(data, state.SelectedIndex, state.FilterText),
- WorkbenchView.Projections => BuildProjectionsView(data, state.SelectedIndex, state.FilterText),
- WorkbenchView.ReadModels => BuildReadModelsView(data, state.SelectedIndex, state.FilterText),
- WorkbenchView.EventStores => BuildEventStoresView(data, state.SelectedIndex),
- WorkbenchView.Namespaces => BuildNamespacesView(data, state.SelectedIndex),
- WorkbenchView.ObserverDetail => BuildObserverDetailPage(data, state.FocusedId, state.SelectedIndex),
- WorkbenchView.FailedPartitionDetail => BuildFailedPartitionDetailPage(data, state.FocusedId, state.ScrollOffset),
- WorkbenchView.EventDetail => BuildEventDetailPage(data, state.FocusedId, state.ScrollOffset),
- WorkbenchView.EventTypeDetail => BuildEventTypeDetailPage(data, state.FocusedId, state.ScrollOffset),
- WorkbenchView.ProjectionDetail => BuildProjectionDetailPage(data, state.FocusedId, state.ScrollOffset),
- WorkbenchView.ReadModelDetail => BuildReadModelDetailPage(data, state.FocusedId, state.ScrollOffset),
- _ => new Markup(string.Empty)
- };
-
- static bool IsFilterableView(WorkbenchView view) =>
- view is WorkbenchView.Observers or WorkbenchView.EventTypes
- or WorkbenchView.EventLog or WorkbenchView.Projections
- or WorkbenchView.ReadModels;
-
- static bool HasNavigation(WorkbenchView view) =>
- view is WorkbenchView.Observers or WorkbenchView.FailedPartitions
- or WorkbenchView.Jobs or WorkbenchView.Recommendations
- or WorkbenchView.EventLog or WorkbenchView.EventTypes
- or WorkbenchView.Projections or WorkbenchView.ReadModels
- or WorkbenchView.EventStores or WorkbenchView.Namespaces
- or WorkbenchView.ObserverDetail;
-
- static bool HasDrillDown(WorkbenchView view) =>
- view is WorkbenchView.Observers or WorkbenchView.FailedPartitions
- or WorkbenchView.EventLog or WorkbenchView.EventTypes
- or WorkbenchView.Projections or WorkbenchView.ReadModels
- or WorkbenchView.EventStores or WorkbenchView.Namespaces
- or WorkbenchView.ObserverDetail;
-
- static string GetActionHints(WorkbenchView view) => view switch
- {
- WorkbenchView.Observers => $"[{_mut}][[ R ]][/] Replay ",
- WorkbenchView.FailedPartitions => $"[{_mut}][[ T ]][/] Retry [{_mut}][[ P ]][/] Replay partition ",
- WorkbenchView.Jobs => $"[{_mut}][[ S ]][/] Stop [{_mut}][[ U ]][/] Resume ",
- WorkbenchView.Recommendations => $"[{_mut}][[ A ]][/] Apply [{_mut}][[ I ]][/] Ignore ",
- WorkbenchView.EventLog => $"[{_mut}][[ S ]][/] Sort [{_mut}][[ PgDn ]][/] Older [{_mut}][[ PgUp ]][/] Newer ",
- WorkbenchView.ObserverDetail => $"[{_mut}][[ R ]][/] Replay [{_mut}][[ P ]][/] Projection ",
- WorkbenchView.FailedPartitionDetail => $"[{_mut}][[ T ]][/] Retry [{_mut}][[ P ]][/] Replay partition ",
- WorkbenchView.EventDetail => $"[{_mut}][[ T ]][/] View event type ",
- WorkbenchView.EventStores => $"[{_mut}][[ Enter ]][/] Switch store ",
- WorkbenchView.Namespaces => $"[{_mut}][[ Enter ]][/] Switch namespace ",
- _ => string.Empty
- };
-
- static string StateIcon(ObserverRunningState state) => state switch
- {
- ObserverRunningState.Active => $"[{_suc}]●[/]",
- ObserverRunningState.Replaying => $"[{_war}]▲[/]",
- ObserverRunningState.Suspended => $"[{_mut}]○[/]",
- ObserverRunningState.Disconnected => $"[{_mut}]⊘[/]",
- _ => $"[{_mut}]·[/]"
- };
-
- static string StateName(ObserverRunningState state) => state switch
- {
- ObserverRunningState.Active => $"[{_suc}]Active[/]",
- ObserverRunningState.Replaying => $"[{_war}]Replaying[/]",
- ObserverRunningState.Suspended => $"[{_mut}]Suspended[/]",
- ObserverRunningState.Disconnected => $"[{_mut}]Disconnected[/]",
- _ => state.ToString().EscapeMarkup()
- };
-
- static int StateOrder(ObserverRunningState state) => state switch
- {
- ObserverRunningState.Disconnected => 0,
- ObserverRunningState.Replaying => 1,
- ObserverRunningState.Active => 2,
- ObserverRunningState.Suspended => 3,
- _ => 4
- };
-
- static ulong? ComputeLag(ObserverInformation obs, ulong? tail)
- {
- if (!tail.HasValue || obs.LastHandledEventSequenceNumber == ulong.MaxValue)
- {
- return tail;
- }
-
- return tail.Value > obs.LastHandledEventSequenceNumber
- ? tail.Value - obs.LastHandledEventSequenceNumber
- : 0;
- }
-
- static string ProgressBar(int success, int total, int width = 16)
- {
- if (total == 0) return $"[{_mut}]{new string('░', width)}[/]";
- var filled = (int)Math.Min(width, (long)success * width / total);
- return $"[{_suc}]{new string('█', filled)}[/][{_mut}]{new string('░', width - filled)}[/]";
- }
-
- static string FormatSeq(ulong seq) =>
- seq == ulong.MaxValue ? $"[{_mut}]—[/]" : seq.ToString("N0");
-
- ///
- /// Renders a block of text with a scrollable viewport and a "lines X–Y of Z" indicator.
- ///
- /// The panel header title.
- /// The full list of lines to display.
- /// The current scroll position (first visible line index).
- /// The panel border color.
- /// A panel showing the visible lines with a scroll indicator.
- static Panel ScrollableText(string title, IReadOnlyList lines, int scrollOffset, Color borderColor)
- {
- var totalLines = lines.Count;
- var start = Math.Max(0, Math.Min(scrollOffset, Math.Max(0, totalLines - ScrollViewport)));
- const int maxOffset = ScrollViewport - 1;
- var end = Math.Min(totalLines - 1, start + maxOffset);
- var visible = lines.Skip(start).Take(ScrollViewport).Select(l => (IRenderable)new Markup(l)).ToList();
- var indicator = totalLines > ScrollViewport
- ? $"\n [{_mut}]lines {start + 1}–{end + 1} of {totalLines} ↑↓ scroll[/]"
- : string.Empty;
-
- var content = visible.Count > 0
- ? (IRenderable)new Rows([.. visible, new Markup(indicator)])
- : new Markup($"[{_mut}](empty)[/]");
-
- return new Panel(content)
- .Header($"[{_acc}] {title.EscapeMarkup()} [/]")
- .Border(BoxBorder.Rounded)
- .BorderColor(borderColor)
- .Expand();
- }
-
- static (int Start, int End) ListWindow(int count, int selectedIndex)
- {
- if (count <= MaxListRows) return (0, count - 1);
- const int maxOffset = MaxListRows - 1;
- var start = Math.Max(0, selectedIndex - (MaxListRows / 2));
- var end = Math.Min(count - 1, start + maxOffset);
- start = Math.Max(0, end - maxOffset);
- return (start, end);
- }
-
- static string DisplayConnectionString(string connectionString)
- {
- try
- {
- var withoutScheme = connectionString.Replace("chronicle://", string.Empty, StringComparison.OrdinalIgnoreCase);
- var afterAuth = withoutScheme.Contains('@') ? withoutScheme[(withoutScheme.IndexOf('@') + 1)..] : withoutScheme;
- var hostPort = afterAuth.Split('?')[0].TrimEnd('/');
- return $"chronicle://{hostPort}";
- }
- catch
- {
- return connectionString;
- }
- }
-}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs
new file mode 100644
index 0000000..b1f95cc
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SColor = SharpConsoleUI.Color;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Identifies a navigation pane section header and its display color.
+/// Instances are held as static readonly fields in
+/// and compared by reference to detect section boundaries.
+///
+/// The all-caps section header text shown in the nav pane.
+/// The color used for the section header and its items.
+public sealed record WorkbenchSection(string Title, SColor Color);
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs
new file mode 100644
index 0000000..777a841
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Persisted workbench state saved between sessions under ~/.cratis/workbench-state.json.
+///
+public class WorkbenchState
+{
+ static readonly string _path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".cratis",
+ "workbench-state.json");
+
+ static readonly JsonSerializerOptions _options = new() { WriteIndented = true };
+
+ /// Gets or sets the refresh interval in seconds.
+ public int Interval { get; set; } = 5;
+
+ /// Gets or sets the index of the last active navigation item.
+ public int LastNavIndex { get; set; }
+
+ ///
+ /// Loads the workbench state from disk, or returns defaults if the file does not exist.
+ ///
+ /// The persisted , or a new default instance.
+ public static WorkbenchState Load()
+ {
+ try
+ {
+ if (!File.Exists(_path)) return new WorkbenchState();
+ var json = File.ReadAllText(_path);
+ return JsonSerializer.Deserialize(json) ?? new WorkbenchState();
+ }
+ catch
+ {
+ return new WorkbenchState();
+ }
+ }
+
+ ///
+ /// Saves the current state to disk. Failures are silently ignored.
+ ///
+ public void Save()
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ File.WriteAllText(_path, JsonSerializer.Serialize(this, _options));
+ }
+ catch
+ {
+ // Best-effort persistence — never throw from a background save.
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs
index 6577181..286fee4 100644
--- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs
@@ -8,9 +8,7 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench;
///
public enum WorkbenchView
{
- // Primary views — cycled with ← → and accessed with keys 1–8.
-
- /// Overview showing server health, observer counts, and any failures.
+ /// Overview showing server health, observer counts, context, and any failures.
Overview = 0,
/// Full list of all observers with their running states and sequence lag.
@@ -25,8 +23,8 @@ public enum WorkbenchView
/// Pending recommendations from the Chronicle server, with apply and ignore actions.
Recommendations = 4,
- /// The live event log — last 50 events, newest first.
- EventLog = 5,
+ /// The event sequences view — recent events from the event log, newest first.
+ EventSequences = 5,
/// All registered event types with their JSON schemas.
EventTypes = 6,
@@ -43,8 +41,20 @@ public enum WorkbenchView
/// All namespaces in the current event store, with the ability to switch the active namespace.
Namespaces = 10,
+ /// Registered OAuth applications on the Chronicle server.
+ Applications = 11,
+
+ /// Registered users on the Chronicle server.
+ Users = 12,
+
+ /// Known identities (actors) in the current event store namespace.
+ Identities = 13,
+
+ /// Event store subscriptions configured for the current event store.
+ Subscriptions = 14,
+
// Detail views — entered with Enter from a primary view, exited with Escape.
- // Values ≥ 100 are never shown in the tab bar.
+ // Values ≥ 100 are never shown in the nav pane.
/// Full-screen detail for a single observer, with a navigable event-type sub-list.
ObserverDetail = 100,
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs
new file mode 100644
index 0000000..f342f9d
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Bundles everything that defines a single workbench navigation item in one place:
+/// the view type, factory, nav item text/icon/subtitle, and the section it belongs to.
+/// The position of this entry in is its view index.
+///
+/// The concrete view type — used for O(n) index lookup by type without creating instances.
+/// Creates a fresh instance of the view. Called once during startup.
+/// The nav item label shown in the sidebar.
+/// The single-character icon shown in compact sidebar mode.
+/// The short subtitle shown below the label in expanded mode.
+/// The section header this item lives under. Use the static readonly
+/// instances from — reference equality is used to detect section boundaries.
+public sealed record WorkbenchViewDefinition(
+ Type ViewType,
+ Func Factory,
+ string NavText,
+ string NavIcon,
+ string NavSubtitle,
+ WorkbenchSection Section);
diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs
new file mode 100644
index 0000000..fde8b57
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Single source of truth for all workbench views.
+///
+/// Every view, its navigation sidebar entry, and its position index are defined here.
+/// Nothing else in the codebase hardcodes view positions, nav labels, or icons.
+///
+///
+/// To add a new view: create the view class, then add ONE to the
+/// master list below in the desired position. Navigation and all index constants update automatically.
+///
+///
+/// asserts at startup when nav item count and view count drift apart.
+///
+///
+public static class WorkbenchViewRegistry
+{
+ // ── Section definitions ────────────────────────────────────────────────────
+ // Static reference objects — WorkbenchNavigation uses ReferenceEquals to detect
+ // section boundaries when building the nav tree.
+
+ /// Overview section header.
+ public static readonly WorkbenchSection SectionOverview = new("OVERVIEW", WorkbenchColors.Accent);
+
+ /// Observation (observers, failures, jobs, recommendations) section header.
+ public static readonly WorkbenchSection SectionObservation = new("OBSERVATION", WorkbenchColors.Warning);
+
+ /// Events (sequences, types) section header.
+ public static readonly WorkbenchSection SectionEvents = new("EVENTS", WorkbenchColors.Teal);
+
+ /// Projections (projections, read models) section header.
+ public static readonly WorkbenchSection SectionProjections = new("PROJECTIONS", WorkbenchColors.Mauve);
+
+ /// Server (event stores, namespaces, applications, users, identities, subscriptions) section header.
+ public static readonly WorkbenchSection SectionServer = new("SERVER", WorkbenchColors.Muted);
+
+ static readonly WorkbenchViewDefinition[] _all =
+ [
+ Entry(static () => new OverviewView(), "Overview", "◈", "Health & status", SectionOverview),
+ Entry(static () => new ObserversView(), "Observers", "◉", "Monitor & replay", SectionObservation),
+ Entry(static () => new FailedPartitionsView(), "Failures", "✕", "Retry failed partitions", SectionObservation),
+ Entry(static () => new JobsView(), "Jobs", "≋", "Background operations", SectionObservation),
+ Entry(static () => new RecommendationsView(), "Recommendations", "★", "Suggested actions", SectionObservation),
+ Entry(static () => new EventSequencesView(), "Event Sequences", "≡", "Appended events", SectionEvents),
+ Entry(static () => new EventTypesView(), "Event Types", "◇", "Registered schemas", SectionEvents),
+ Entry(static () => new ProjectionsView(), "Projections", "▷", "Running projections", SectionProjections),
+ Entry(static () => new ReadModelsView(), "Read Models", "▦", "Derived state", SectionProjections),
+ Entry(static () => new EventStoresView(), "Event Stores", "⊞", "Store management", SectionServer),
+ Entry(static () => new NamespacesView(), "Namespaces", "⊙", "Namespace context", SectionServer),
+ Entry(static () => new ApplicationsView(), "Applications", "⊕", "App registrations", SectionServer),
+ Entry(static () => new UsersView(), "Users", "♟", "User management", SectionServer),
+ Entry(static () => new IdentitiesView(), "Identities", "◎", "Identity records", SectionServer),
+ Entry(static () => new SubscriptionsView(), "Subscriptions", "⊗", "Active subscriptions", SectionServer),
+ ];
+
+ /// The ordered list of all registered view definitions. Position = view index.
+ public static IReadOnlyList All => _all;
+
+ ///
+ /// Returns the zero-based view index for .
+ /// Throws if the type is not registered — this is a programming error.
+ ///
+ /// The view type to look up.
+ /// The view index matching the corresponding IndexXxx constant.
+ /// Thrown when is not in the registry.
+ public static int IndexOf()
+ where TView : IWorkbenchView
+ {
+ var target = typeof(TView);
+ for (var i = 0; i < _all.Length; i++)
+ {
+ if (_all[i].ViewType == target)
+ {
+ return i;
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"View type {typeof(TView).Name} is not registered in {nameof(WorkbenchViewRegistry)}. " +
+ $"Add an Entry<{typeof(TView).Name}>(...) to the master list.");
+ }
+
+ ///
+ /// Creates one fresh instance of every registered view, in registry order.
+ /// The returned array is the shared _views[] for MainWindow and WorkbenchNavigation.
+ ///
+ /// New view instances in registry order.
+ public static IWorkbenchView[] CreateViews() =>
+ [.. _all.Select(d => d.Factory())];
+
+ static WorkbenchViewDefinition Entry(
+ Func factory,
+ string navText,
+ string navIcon,
+ string navSubtitle,
+ WorkbenchSection section)
+ where TView : IWorkbenchView =>
+ new(typeof(TView), factory, navText, navIcon, navSubtitle, section);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs
new file mode 100644
index 0000000..ee79014
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Chronicle.Contracts.Security;
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Applications navigation item — filterable table of registered OAuth applications with a detail pane.
+///
+public class ApplicationsView : FilterableTableView
+{
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("ClientId", TextJustification.Left, null),
+ ("Active", TextJustification.Left, 8),
+ ("Created", TextJustification.Left, 30)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "APPLICATION";
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.Applications.OrderBy(a => a.ClientId);
+
+ ///
+ protected override string GetKey(Application item) => item.Id.ToString();
+
+ ///
+ protected override string[] BuildRow(Application item)
+ {
+ var activeColor = item.IsActive ? WorkbenchColors.Success.ToMarkup() : WorkbenchColors.Muted.ToMarkup();
+ return
+ [
+ item.ClientId,
+ $"[{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]",
+ item.CreatedAt.ToString()
+ ];
+ }
+
+ ///
+ protected override string RenderDetail(Application? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an application.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+ var activeColor = item.IsActive ? suc : mut;
+
+ return string.Join(
+ "\n",
+ $"[{mut}]Id[/] {item.Id}",
+ $"[{mut}]ClientId[/] {item.ClientId}",
+ $"[{mut}]Active[/] [{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]",
+ $"[{mut}]Created[/] {item.CreatedAt}");
+ }
+
+ ///
+ protected override bool MatchesFilter(Application item, string filter) =>
+ item.ClientId.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs
new file mode 100644
index 0000000..2b433af
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs
@@ -0,0 +1,156 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Event Sequences navigation item — filterable, sortable table of recent events with a detail pane showing event content.
+///
+public class EventSequencesView : FilterableTableView
+{
+ /// Gets the currently selected event, or if none is selected.
+ public AppendedEvent? SelectedEvent => SelectedItem;
+
+ ///
+ /// Gets or sets the callback invoked when the user requests to view the event type definition.
+ ///
+ public Action? OnViewEventTypeDefinition { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests to view observers for this event type.
+ ///
+ public Action? OnViewObserversForType { get; set; }
+
+ ///
+ public override string ViewHelp =>
+ "Shows recent events appended to the event log.\n" +
+ " [D] Navigate to the selected event's type definition\n" +
+ " [V] Find observers subscribed to this event type";
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("#", TextJustification.Right, 14),
+ ("Occurred", TextJustification.Left, 22),
+ ("Event Type", TextJustification.Left, null),
+ ("Source", TextJustification.Left, 30)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "EVENT";
+
+ /// Uses teal to match the EVENTS section color.
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Teal;
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) => data.RecentEvents;
+
+ ///
+ protected override string GetKey(AppendedEvent item) => item.Context.SequenceNumber.ToString();
+
+ ///
+ protected override string[] BuildRow(AppendedEvent item) =>
+ [
+ item.Context.SequenceNumber.ToString().PadLeft(14),
+ FormatRelativeTime(item.Context.Occurred),
+ item.Context.EventType.Id,
+ item.Context.EventSourceId ?? string.Empty
+ ];
+
+ ///
+ protected override IReadOnlyList GetAvailableActions(AppendedEvent item)
+ {
+ List actions = [];
+ if (OnViewEventTypeDefinition is not null)
+ {
+ actions.Add(new ViewAction("View event type definition", "D", ConsoleKey.D, default, () => OnViewEventTypeDefinition(item)));
+ }
+
+ if (OnViewObserversForType is not null)
+ {
+ actions.Add(new ViewAction("View observers for this type", "V", ConsoleKey.V, default, () => OnViewObserversForType(item)));
+ }
+
+ return actions;
+ }
+
+ ///
+ protected override IComparer GetColumnComparer(int columnIndex) => columnIndex switch
+ {
+ 0 => Comparer.Create((a, b) =>
+ a.Context.SequenceNumber.CompareTo(b.Context.SequenceNumber)),
+ 1 => Comparer.Create((a, b) =>
+ ((DateTimeOffset)a.Context.Occurred).CompareTo((DateTimeOffset)b.Context.Occurred)),
+ _ => base.GetColumnComparer(columnIndex)
+ };
+
+ ///
+ protected override string RenderDetail(AppendedEvent? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]";
+ }
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var contentText = !string.IsNullOrEmpty(item.Content)
+ ? JsonYamlFormatter.FormatAsYaml(item.Content, mut)
+ : $"[{mut}](no content)[/]";
+
+ return string.Join('\n', new[]
+ {
+ $"[{mut}]Seq#[/] {item.Context.SequenceNumber:N0}",
+ $"[{mut}]Type[/] [{acc}]{item.Context.EventType.Id}[/] gen {item.Context.EventType.Generation}",
+ $"[{mut}]Source[/] {item.Context.EventSourceId ?? "—"}",
+ $"[{mut}]Occurred[/] {item.Context.Occurred}",
+ $"[{mut}]Correlation[/] {item.Context.CorrelationId}",
+ string.Empty,
+ $"[{acc}]Content:[/]",
+ $" [{mut}][D][/] View event type definition",
+ $" [{mut}][V][/] View observers for this type",
+ contentText
+ });
+ }
+
+ ///
+ protected override bool MatchesFilter(AppendedEvent item, string filter)
+ {
+ if (filter.StartsWith("type:", StringComparison.OrdinalIgnoreCase))
+ {
+ return item.Context.EventType.Id.Contains(filter[5..], StringComparison.OrdinalIgnoreCase);
+ }
+
+ return item.Context.EventType.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ (item.Context.EventSourceId ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ protected override IEnumerable GetCompletions(string input)
+ {
+ var items = PendingData?.RecentEvents ?? [];
+ return items
+ .Select(e => $"type:{e.Context.EventType.Id}")
+ .Distinct()
+ .Where(c => c.Contains(input, StringComparison.OrdinalIgnoreCase))
+ .Order();
+ }
+
+ ///
+ /// Formats a as a human-readable relative time (e.g., "3s ago", "12m ago").
+ /// Falls back to an absolute format for timestamps older than 7 days.
+ ///
+ /// The event timestamp.
+ /// A relative or absolute time string.
+ static string FormatRelativeTime(DateTimeOffset occurred)
+ {
+ var diff = DateTimeOffset.UtcNow - occurred.ToUniversalTime();
+ if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s ago";
+ if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
+ if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
+ if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
+ return occurred.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs
new file mode 100644
index 0000000..f771dcd
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using UITableRow = SharpConsoleUI.Controls.TableRow;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Event Stores tab — list of available event stores with a Switch action.
+/// Selecting an entry and pressing Enter switches the active event store.
+///
+public class EventStoresView : IWorkbenchView
+{
+ TableControl? _table;
+ MarkupControl? _helpPane;
+ WorkbenchData? _pendingData;
+
+ ///
+ public bool IsActive { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user switches to a different event store.
+ ///
+ public Action? OnSwitch { get; set; }
+
+ ///
+ public void Dispose()
+ {
+ _table?.Dispose();
+ _helpPane?.Dispose();
+ }
+
+ ///
+ public IWindowControl BuildContent(ConsoleWindowSystem windowSystem)
+ {
+ _table = Controls.Table()
+ .AddColumn("Event Store", SharpConsoleUI.Layout.TextJustification.Left, null)
+ .Interactive()
+ .WithFiltering()
+ .WithSorting()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .OnRowActivated((_, _) => SwitchToSelected())
+ .WithName("EventStoresTable")
+ .Build();
+
+ _helpPane = new MarkupControl(
+ [
+ $"[{WorkbenchColors.Accent.ToMarkup()}][bold]SWITCH EVENT STORE[/][/]",
+ string.Empty,
+ $" [{WorkbenchColors.Muted.ToMarkup()}]Select a store and press[/] [bold]Enter[/] [{WorkbenchColors.Muted.ToMarkup()}]to switch.[/]"
+ ])
+ { Name = "EventStoresHelp" };
+
+ var root = HorizontalGridControl.Create()
+ .Column(c => c.Add(_table))
+ .WithSplitterAfter(0)
+ .Column(c => c.Width(44).Add(_helpPane))
+ .Build();
+
+ // Apply any data that arrived before controls were ready (NavigationView lazy init).
+ if (_pendingData is not null)
+ UpdateData(_pendingData);
+
+ return root;
+ }
+
+ ///
+ public void UpdateData(WorkbenchData data)
+ {
+ _pendingData = data;
+ if (_table is null) return;
+
+ var selectedKey = _table.SelectedRow?.Tag as string;
+
+ _table.ClearRows();
+ foreach (var name in data.EventStoreNames.Order())
+ {
+ _table.AddRow(new UITableRow([name]) { Tag = name });
+ }
+
+ if (selectedKey is not null)
+ {
+ RestoreSelection(selectedKey);
+ }
+
+ if (_helpPane is null) return;
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+
+ var lines = new List
+ {
+ $"[{acc}][bold]SWITCH EVENT STORE[/][/]",
+ string.Empty,
+ $"[{mut}]Active[/] [{suc}]{data.EventStore}[/]",
+ $"[{mut}]Available[/] {data.EventStoreNames.Count}",
+ string.Empty,
+ $" [{mut}]Select a store and press[/] [bold]Enter[/]",
+ $" [{mut}]to make it the active event store.[/]"
+ };
+
+ _helpPane.Text = string.Join('\n', lines);
+ }
+
+ void RestoreSelection(string key)
+ {
+ if (_table is null) return;
+
+ for (var i = 0; i < _table.Rows.Count; i++)
+ {
+ if (_table.Rows[i].Tag is string name && name == key)
+ {
+ _table.SelectedRowIndex = i;
+ return;
+ }
+ }
+ }
+
+ void SwitchToSelected()
+ {
+ if (_table?.SelectedRow?.Tag is string storeName)
+ {
+ OnSwitch?.Invoke(storeName);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs
new file mode 100644
index 0000000..1e53332
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs
@@ -0,0 +1,127 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Event Types navigation item — filterable table of registered event types with schema details in the right pane.
+///
+public class EventTypesView : FilterableTableView
+{
+ /// Gets the currently selected event type registration, or if none is selected.
+ public EventTypeRegistration? SelectedEventType => SelectedItem;
+
+ ///
+ /// Gets or sets the callback invoked when the user requests to view observers for the selected event type.
+ ///
+ public Action? OnViewObservers { get; set; }
+
+ ///
+ public override string ViewHelp =>
+ "Lists all registered event types and their schemas.\n" +
+ " [V] Find observers subscribed to the selected event type";
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Id", TextJustification.Left, null),
+ ("Gen", TextJustification.Right, 6),
+ ("Owner", TextJustification.Left, 20)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "EVENT TYPE";
+
+ /// Uses teal to match the EVENTS section color.
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Teal;
+
+ ///
+ protected override int DefaultSortColumn => 0;
+
+ ///
+ protected override SortDirection DefaultSortDirection => SortDirection.Ascending;
+
+ ///
+ protected override bool IsSortableColumn(int columnIndex) => columnIndex == 0;
+
+ ///
+ protected override IReadOnlyList GetAvailableActions(EventTypeRegistration item)
+ {
+ List actions = [];
+ if (OnViewObservers is not null)
+ {
+ actions.Add(new ViewAction("View observers for this type", "V", ConsoleKey.V, default, () => OnViewObservers(item)));
+ }
+
+ return actions;
+ }
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.EventTypeRegistrations.OrderBy(r => r.Type.Id).ThenBy(r => r.Type.Generation);
+
+ ///
+ protected override string GetKey(EventTypeRegistration item) => $"{item.Type.Id}+{item.Type.Generation}";
+
+ ///
+ protected override string[] BuildRow(EventTypeRegistration item) =>
+ [item.Type.Id, item.Type.Generation.ToString().PadLeft(6), item.Owner.ToString()];
+
+ ///
+ protected override string RenderDetail(EventTypeRegistration? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]";
+ }
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var schemaContent = !string.IsNullOrEmpty(item.Schema)
+ ? JsonYamlFormatter.FormatAsYaml(item.Schema, mut)
+ : $"[{mut}](no schema)[/]";
+
+ return string.Join('\n', new[]
+ {
+ $"[{mut}]Id[/] {item.Type.Id}",
+ $"[{mut}]Generation[/] {item.Type.Generation}",
+ $"[{mut}]Owner[/] {item.Owner}",
+ $"[{mut}]Source[/] {item.Source}",
+ $"[{mut}]Tombstone[/] {item.Type.Tombstone}",
+ string.Empty,
+ $"[{acc}]Schema:[/]",
+ schemaContent,
+ string.Empty,
+ $"[{acc}]Actions:[/]",
+ $" [{mut}][V][/] View observers for this type"
+ });
+ }
+
+ ///
+ protected override bool MatchesFilter(EventTypeRegistration item, string filter)
+ {
+ if (filter.StartsWith("owner:", StringComparison.OrdinalIgnoreCase))
+ {
+ return item.Owner.ToString().Contains(filter[6..], StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (filter.StartsWith("gen:", StringComparison.OrdinalIgnoreCase))
+ {
+ return item.Type.Generation.ToString().Contains(filter[4..], StringComparison.OrdinalIgnoreCase);
+ }
+
+ return item.Type.Id.Contains(filter, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ protected override IEnumerable GetCompletions(string input) =>
+ [
+ "owner:client",
+ "owner:server",
+ "gen:1",
+ "gen:2"
+ ];
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs
new file mode 100644
index 0000000..7f62b73
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Failed Partitions navigation item — filterable table of failed partitions with retry/replay actions.
+///
+public class FailedPartitionsView : FilterableTableView
+{
+ /// Gets the currently selected failed partition, or if none is selected.
+ public FailedPartition? SelectedPartition => SelectedItem;
+
+ ///
+ public override string ViewHelp =>
+ "Lists partitions that have failed during event processing.\n" +
+ " [T] Retry the selected partition (re-process from last failure)\n" +
+ " [P] Replay the selected partition from the beginning\n" +
+ " [Space] Check / uncheck row for bulk operations\n" +
+ " [T] / [P] (with 2+ checked) Bulk retry / replay all checked partitions";
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a partition retry.
+ ///
+ public Action? OnRetryPartition { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a partition replay.
+ ///
+ public Action? OnReplayPartition { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk retry of all checked partitions.
+ ///
+ public Action>? OnRetryAll { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk replay of all checked partitions.
+ ///
+ public Action>? OnReplayAll { get; set; }
+
+ ///
+ /// Gets all failed partitions that are currently checked (checkbox mode).
+ ///
+ public IReadOnlyList Checked => CheckedItems;
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Observer", TextJustification.Left, null),
+ ("Partition", TextJustification.Left, 30),
+ ("Attempts", TextJustification.Right, 10)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "FAILED PARTITION";
+
+ ///
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Danger;
+
+ ///
+ protected override bool HasCheckboxMode => true;
+
+ ///
+ protected override IReadOnlyList GetAvailableActions(FailedPartition item)
+ {
+ List actions = [];
+ if (OnRetryPartition is not null)
+ {
+ actions.Add(new ViewAction("Retry partition", "T", ConsoleKey.T, default, () => OnRetryPartition(item)));
+ }
+
+ if (OnReplayPartition is not null)
+ {
+ actions.Add(new ViewAction("Replay partition", "P", ConsoleKey.P, default, () => OnReplayPartition(item)));
+ }
+
+ var checkedItems = Checked;
+ if (OnRetryAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Retry {checkedItems.Count} checked", null, null, default, () => OnRetryAll(checkedItems)));
+ }
+
+ if (OnReplayAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Replay {checkedItems.Count} checked", null, null, default, () => OnReplayAll(checkedItems)));
+ }
+
+ return actions;
+ }
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.FailedPartitions.OrderByDescending(p => p.Attempts.Count());
+
+ ///
+ protected override string GetKey(FailedPartition item) => $"{item.ObserverId}/{item.Partition}";
+
+ ///
+ protected override string[] BuildRow(FailedPartition item) =>
+ [item.ObserverId, item.Partition, item.Attempts.Count().ToString().PadLeft(10)];
+
+ ///
+ protected override string RenderDetail(FailedPartition? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var dan = WorkbenchColors.Danger.ToMarkup();
+ var acc = WorkbenchColors.Accent.ToMarkup();
+
+ var lines = new List
+ {
+ $"[{mut}]Observer[/] {item.ObserverId}",
+ $"[{mut}]Partition[/] [{dan}]{item.Partition}[/]",
+ $"[{mut}]Attempts[/] {item.Attempts.Count()}",
+ string.Empty,
+ $"[{acc}]Last Attempts:[/]"
+ };
+
+ foreach (var attempt in item.Attempts.OrderByDescending(a => a.Occurred).Take(5))
+ {
+ lines.Add($" [{mut}]{attempt.Occurred}[/]");
+ var firstMessage = attempt.Messages?.FirstOrDefault();
+ if (!string.IsNullOrEmpty(firstMessage))
+ {
+ var msg = firstMessage.Length > 80 ? firstMessage[..77] + "…" : firstMessage;
+ lines.Add($" [{dan}]{msg}[/]");
+ }
+ }
+
+ if (OnRetryPartition is not null || OnReplayPartition is not null)
+ {
+ lines.Add(string.Empty);
+ if (OnRetryPartition is not null)
+ {
+ lines.Add($"[{mut}]Press[/] [bold]T[/] [{mut}]to retry[/]");
+ }
+
+ if (OnReplayPartition is not null)
+ {
+ lines.Add($"[{mut}]Press[/] [bold]P[/] [{mut}]to replay[/]");
+ }
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ ///
+ protected override bool MatchesFilter(FailedPartition item, string filter) =>
+ item.ObserverId.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Partition.Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs
new file mode 100644
index 0000000..42ce0ab
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs
@@ -0,0 +1,708 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Animation;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Events;
+using SharpConsoleUI.Helpers;
+using SharpConsoleUI.Layout;
+using UITableRow = SharpConsoleUI.Controls.TableRow;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Abstract base for workbench views that display a filterable, sortable, paginated table with a detail panel.
+/// Sorting is applied to the full filtered dataset before pagination — sort order is always consistent
+/// across all pages. Per-column CustomRowComparer delegates ensure typed (not string) comparison.
+/// Subclasses only implement domain-specific concerns.
+///
+/// The domain item type displayed in each row.
+public abstract class FilterableTableView : IWorkbenchView
+{
+ /// Rows consumed by non-table chrome. Subtracted from terminal height to compute page size.
+ const int NonTableRowOverhead = 14;
+
+ /// Minimum number of table rows to show regardless of terminal height.
+ const int MinPageSize = 5;
+
+ ConsoleWindowSystem? _windowSystem;
+ HorizontalGridControl? _root;
+ TableControl? _table;
+ PanelControl? _detailPanel;
+ PromptControl? _filterPrompt;
+ MarkupControl? _pageIndicator;
+ ButtonControl? _prevPageButton;
+ ButtonControl? _nextPageButton;
+ bool _detailPaneVisible = true;
+ WorkbenchData? _pendingData;
+ string _currentFilter = string.Empty;
+ List _allItems = [];
+ int _pageIndex;
+
+ int _lastAppliedSortColumn = -1;
+ SortDirection _lastAppliedSortDirection = SortDirection.None;
+
+ ///
+ public Action? OnFilterFocusChanged { get; set; }
+
+ ///
+ public bool IsActive { get; set; }
+
+ /// Gets the primary focus target for this view (the main table).
+ public IWindowControl? PrimaryFocusTarget => _table;
+
+ ///
+ public string? DetailContent => _detailPanel?.Content;
+
+ /// Gets the per-view help text shown in the help overlay.
+ public virtual string ViewHelp => string.Empty;
+
+ ///
+ public IReadOnlyList ViewActions =>
+ SelectedItem is TItem item ? GetAvailableActions(item) : [];
+
+ /// Gets column definitions: (name, justification, fixed width or null for flex).
+ protected abstract IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns { get; }
+
+ /// Gets the header label shown on the right detail panel.
+ protected virtual string DetailPanelHeader => "DETAIL";
+
+ /// Gets the border color for the right detail panel.
+ protected virtual SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Accent;
+
+ /// Gets a value indicating whether to enable checkbox multi-select mode on the table.
+ protected virtual bool HasCheckboxMode => false;
+
+ /// Gets the zero-based column index to use for the initial sort. -1 = no initial sort.
+ protected virtual int DefaultSortColumn => -1;
+
+ /// Gets the sort direction applied alongside on first display.
+ protected virtual SortDirection DefaultSortDirection => SortDirection.None;
+
+ /// Gets the width of the right-hand detail pane in character columns.
+ protected virtual int DetailPaneWidth => Math.Max(30, Console.WindowWidth / 3);
+
+ /// Gets the pending data snapshot.
+ protected WorkbenchData? PendingData => _pendingData;
+
+ /// Gets the currently selected item, or if no row is selected.
+ protected TItem? SelectedItem =>
+ _table?.SelectedRow?.Tag is TItem item ? item : default;
+
+ /// Gets all items that are currently checked (checkbox mode only).
+ protected IReadOnlyList CheckedItems
+ {
+ get
+ {
+ if (_table is null)
+ {
+ return [];
+ }
+
+ return [.. _table.GetCheckedRows().Select(r => r.Tag).OfType()];
+ }
+ }
+
+ int PageSize => Math.Max(MinPageSize, Console.WindowHeight - NonTableRowOverhead);
+
+ ///
+ public virtual IWindowControl BuildContent(ConsoleWindowSystem windowSystem)
+ {
+ _windowSystem = windowSystem;
+ var tableBuilder = Controls.Table();
+
+ foreach (var (name, justify, width) in Columns)
+ {
+ tableBuilder.AddColumn(name, justify, width);
+ }
+
+ tableBuilder = tableBuilder
+ .Interactive()
+ .WithSorting()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .OnSelectedRowChanged((_, _) => RefreshDetail())
+ .OnRowActivated((_, _) => ActivateSelected())
+ .WithName($"{GetType().Name}Table");
+
+ if (HasCheckboxMode)
+ {
+ tableBuilder = tableBuilder.WithCheckboxMode();
+ }
+
+ _table = tableBuilder.Build();
+
+ // SortByColumn (called on header click) does NOT fire PropertyChanged for SortColumnIndex
+ // or CurrentSortDirection — those properties have no setters. MouseClick fires after
+ // SortByColumn completes, giving us the correct new sort state to detect header clicks.
+ _table.MouseClick += OnTableMouseClick;
+ _table.MouseRightClick += OnTableRightClick;
+
+ // Wire per-column typed comparers so SortByColumn uses our comparers, not string comparison.
+ // This ensures the sort map produced by SortByColumn matches our pre-sorted page data.
+ var columns = _table.Columns;
+ for (var i = 0; i < columns.Count; i++)
+ {
+ var colIndex = i;
+ var col = columns[i];
+ col.IsSortable = IsSortableColumn(colIndex);
+ var comparer = GetColumnComparer(colIndex);
+ col.CustomRowComparer = (a, b) =>
+ a.Tag is TItem ta && b.Tag is TItem tb ? comparer.Compare(ta, tb) : 0;
+ }
+
+ _pageIndicator = new MarkupControl([string.Empty]) { Name = $"{GetType().Name}Page" };
+
+ _prevPageButton = Controls.Button(" ◄ ")
+ .OnClick((_, _) => PreviousPage())
+ .WithName($"{GetType().Name}PrevPage")
+ .Build();
+
+ _nextPageButton = Controls.Button(" ► ")
+ .OnClick((_, _) => NextPage())
+ .WithName($"{GetType().Name}NextPage")
+ .Build();
+
+ _filterPrompt = Controls.Prompt("/ filter: ")
+ .WithHistory(true)
+ .WithTabCompleter((input, _) => GetCompletions(input))
+ .OnInputChanged((_, text) =>
+ {
+ _currentFilter = text ?? string.Empty;
+ _pageIndex = 0;
+ RebuildRows();
+ })
+ .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true))
+ .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false))
+ .WithName($"{GetType().Name}Filter")
+ .Build();
+
+ _detailPanel = Controls.Panel()
+ .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an item.[/]")
+ .WithHeader($" {DetailPanelHeader} ")
+ .Rounded()
+ .WithBorderColor(DetailBorderColor)
+ .WithPadding(1, 0, 1, 0)
+ .FillVertical()
+ .WithName($"{GetType().Name}Detail")
+ .Build();
+
+ var pageNavRow = HorizontalGridControl.Create()
+ .Column(c => c.Width(5).Add(_prevPageButton))
+ .Column(c => c.Add(_pageIndicator))
+ .Column(c => c.Width(5).Add(_nextPageButton))
+ .Build();
+
+ var leftPane = Controls.ScrollablePanel()
+ .AddControl(_filterPrompt)
+ .AddControl(_table)
+ .AddControl(pageNavRow)
+ .WithVerticalScroll(ScrollMode.None)
+ .Build();
+
+ _root = HorizontalGridControl.Create()
+ .Column(c => c.Add(leftPane))
+ .WithSplitterAfter(0)
+ .Column(c => c.Width(DetailPaneWidth).Add(_detailPanel))
+ .Build();
+
+ if (_pendingData is not null)
+ {
+ var wasActive = IsActive;
+ IsActive = false;
+ UpdateData(_pendingData);
+ IsActive = wasActive;
+ _filterPrompt.Input = _currentFilter;
+ }
+
+ return _root;
+ }
+
+ ///
+ public void UpdateData(WorkbenchData data)
+ {
+ _pendingData = data;
+
+ if (_table is null)
+ {
+ return;
+ }
+
+ _allItems = [.. GetItems(data)];
+
+ if (!IsActive)
+ {
+ RebuildRows();
+ }
+ }
+
+ ///
+ public void ActivateFilter(Window window)
+ {
+ if (_filterPrompt is not null)
+ {
+ window.FocusControl(_filterPrompt);
+ }
+ }
+
+ ///
+ public void ClearFilter()
+ {
+ _currentFilter = string.Empty;
+ SetFilterInput(string.Empty);
+ RebuildRows();
+ }
+
+ ///
+ public void ToggleDetailPane()
+ {
+ if (_root is null)
+ {
+ return;
+ }
+
+ _detailPaneVisible = !_detailPaneVisible;
+ var targetWidth = _detailPaneVisible ? DetailPaneWidth : 0;
+ _root.AnimateColumnWidth(1, targetWidth, TimeSpan.FromMilliseconds(180), EasingFunctions.EaseInOut);
+ }
+
+ ///
+ public void MoveSelectionDown()
+ {
+ if (_table is null || _table.SelectedRowIndex >= _table.Rows.Count - 1)
+ {
+ return;
+ }
+
+ _table.SelectedRowIndex++;
+ }
+
+ ///
+ public void MoveSelectionUp()
+ {
+ if (_table is null || _table.SelectedRowIndex <= 0)
+ {
+ return;
+ }
+
+ _table.SelectedRowIndex--;
+ }
+
+ ///
+ public void JumpToFirstRow()
+ {
+ if (_table is null || _table.Rows.Count == 0)
+ {
+ return;
+ }
+
+ _table.SelectedRowIndex = 0;
+ }
+
+ ///
+ public void JumpToLastRow()
+ {
+ if (_table is null || _table.Rows.Count == 0)
+ {
+ return;
+ }
+
+ _table.SelectedRowIndex = _table.Rows.Count - 1;
+ }
+
+ ///
+ public void NextPage()
+ {
+ if (_table is null)
+ {
+ return;
+ }
+
+ var totalPages = ComputeTotalPages(GetFiltered().Count);
+ if (_pageIndex < totalPages - 1)
+ {
+ _pageIndex++;
+ RebuildRows();
+ }
+ }
+
+ ///
+ public void PreviousPage()
+ {
+ if (_table is null || _pageIndex <= 0)
+ {
+ return;
+ }
+
+ _pageIndex--;
+ RebuildRows();
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_table is not null)
+ {
+ _table.MouseClick -= OnTableMouseClick;
+ _table.MouseRightClick -= OnTableRightClick;
+ }
+
+ _root?.Dispose();
+ _table?.Dispose();
+ _detailPanel?.Dispose();
+ _filterPrompt?.Dispose();
+ _pageIndicator?.Dispose();
+ _prevPageButton?.Dispose();
+ _nextPageButton?.Dispose();
+ }
+
+ /// Sets the filter text and rebuilds the table rows.
+ /// The filter string to apply.
+ public void SetFilter(string filter)
+ {
+ _currentFilter = filter;
+ _pageIndex = 0;
+ SetFilterInput(filter);
+ RebuildRows();
+ }
+
+ /// Extracts the relevant items from the snapshot.
+ /// The current workbench data snapshot.
+ /// The items to display in the table.
+ protected abstract IEnumerable GetItems(WorkbenchData data);
+
+ /// Returns a stable string key that uniquely identifies this item.
+ /// The item.
+ /// A unique string key.
+ protected abstract string GetKey(TItem item);
+
+ /// Returns cell values for the table row (must match count).
+ /// The item to render.
+ /// Cell values for each column, may contain markup.
+ protected abstract string[] BuildRow(TItem item);
+
+ /// Returns the markup string shown in the right detail panel for the selected item.
+ /// The selected item, or if nothing is selected.
+ /// The current workbench data snapshot.
+ /// Markup content for the detail panel.
+ protected abstract string RenderDetail(TItem? item, WorkbenchData? data);
+
+ /// Returns true if the item matches the given filter text.
+ /// The item to test.
+ /// The current filter string.
+ /// if the item passes the filter.
+ protected abstract bool MatchesFilter(TItem item, string filter);
+
+ ///
+ /// Returns the comparer used to sort the given column.
+ /// Default: OrdinalIgnoreCase string comparison on the rendered cell text.
+ /// Override for numeric, date, or enum columns to get correct cross-page sort order.
+ ///
+ /// The zero-based column index being sorted.
+ /// A comparer for .
+ protected virtual IComparer GetColumnComparer(int columnIndex) =>
+ Comparer.Create((a, b) => string.Compare(
+ Markup.Remove(BuildRow(a).ElementAtOrDefault(columnIndex) ?? string.Empty),
+ Markup.Remove(BuildRow(b).ElementAtOrDefault(columnIndex) ?? string.Empty),
+ StringComparison.OrdinalIgnoreCase));
+
+ /// Tab-completion tokens for the filter prompt. Default: none.
+ /// The current user input.
+ /// Completion suggestions.
+ protected virtual IEnumerable GetCompletions(string input) => [];
+
+ /// Called when Enter is pressed on a row. Default: no-op.
+ /// The activated item.
+ protected virtual void OnRowActivated(TItem item)
+ {
+ }
+
+ ///
+ /// Returns the actions available when is selected.
+ /// Override in action views to expose view-specific actions to the keyboard dispatcher
+ /// and right-click context menu.
+ ///
+ /// The currently selected item.
+ /// The list of actions the user can invoke on this item.
+ protected virtual IReadOnlyList GetAvailableActions(TItem item) => [];
+
+ ///
+ /// Returns when the user may sort by the given column.
+ /// Override to restrict sorting to specific columns only.
+ ///
+ /// The zero-based column index to test.
+ /// if the column is sortable.
+ protected virtual bool IsSortableColumn(int columnIndex) => true;
+
+ static int ContextMenuWidth(List actions)
+ {
+ var maxLabel = actions.Max(a => a.Label.Length);
+ var maxHint = actions.Max(a => a.KeyHint?.Length ?? 0);
+ return maxLabel + (maxHint > 0 ? maxHint + 4 : 0) + 4;
+ }
+
+ static void SetButtonEnabled(ButtonControl? button, bool enabled)
+ {
+ if (button is null)
+ {
+ return;
+ }
+
+ button.IsEnabled = enabled;
+ }
+
+ int ComputeTotalPages(int itemCount)
+ {
+ var pageSize = PageSize;
+ return Math.Max(1, (itemCount + pageSize - 1) / pageSize);
+ }
+
+ List GetFiltered() =>
+ string.IsNullOrEmpty(_currentFilter)
+ ? _allItems
+ : [.. _allItems.Where(item => MatchesFilter(item, _currentFilter))];
+
+ List ApplySort(List items, int sortCol, SortDirection sortDir)
+ {
+ var col = sortCol >= 0 ? sortCol : DefaultSortColumn;
+ var dir = sortDir != SortDirection.None ? sortDir : DefaultSortDirection;
+
+ if (col < 0 || dir == SortDirection.None)
+ {
+ return items;
+ }
+
+ var comparer = GetColumnComparer(col);
+ return dir == SortDirection.Ascending
+ ? [.. items.Order(comparer)]
+ : [.. items.OrderDescending(comparer)];
+ }
+
+ void SetFilterInput(string value)
+ {
+ if (_filterPrompt is null)
+ {
+ return;
+ }
+
+ _filterPrompt.Input = value;
+ }
+
+ ///
+ /// Fires after every left-click on the table, including header clicks.
+ /// SortByColumn runs before MouseClick fires, so we can compare the table's new
+ /// sort state against what we applied last time. When it differs, a header click changed the
+ /// sort and we rebuild the full dataset with the new sort applied across all pages.
+ ///
+ /// The event source.
+ /// Mouse event args.
+ void OnTableMouseClick(object? sender, MouseEventArgs e)
+ {
+ if (_table is null)
+ {
+ return;
+ }
+
+ var newCol = _table.SortColumnIndex;
+ var newDir = _table.CurrentSortDirection;
+
+ if (newCol != _lastAppliedSortColumn || newDir != _lastAppliedSortDirection)
+ {
+ RebuildRows();
+ }
+ }
+
+ void OnTableRightClick(object? sender, MouseEventArgs e)
+ {
+ if (_windowSystem is null || SelectedItem is not TItem item)
+ {
+ return;
+ }
+
+ var actions = GetAvailableActions(item).ToList();
+ if (actions.Count == 0)
+ {
+ return;
+ }
+
+ ShowContextMenu(e.AbsolutePosition.X, e.AbsolutePosition.Y, actions);
+ }
+
+ void ShowContextMenu(int x, int y, List actions)
+ {
+ if (_windowSystem is null)
+ {
+ return;
+ }
+
+ var menuBuilder = Controls.Menu().Vertical()
+ .WithMenuBarColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background)
+ .WithDropdownColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background);
+
+ foreach (var action in actions)
+ {
+ menuBuilder.AddItem(action.Label, action.KeyHint ?? string.Empty, action.Execute);
+ }
+
+ var menu = menuBuilder.Build();
+ Window? contextWindow = null;
+
+ menu.ItemSelected += (_, _) => _windowSystem.CloseWindow(contextWindow, activateParent: true, force: false);
+
+ var width = Math.Max(20, ContextMenuWidth(actions));
+ var height = actions.Count + 2;
+ var clampedX = Math.Max(0, Math.Min(x, Console.WindowWidth - width));
+ var clampedY = Math.Max(0, Math.Min(y, Console.WindowHeight - height));
+
+ contextWindow = new WindowBuilder(_windowSystem)
+ .WithTitle(string.Empty)
+ .HideTitle()
+ .HideCloseButton()
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithSize(width, height)
+ .AtPosition(clampedX, clampedY)
+ .WithCloseOnDeactivate(true)
+ .AddControl(menu)
+ .OnKeyPressed((_, ke) =>
+ {
+ if (ke.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ _windowSystem.CloseWindow(contextWindow, activateParent: true, force: false);
+ ke.Handled = true;
+ }
+ })
+ .Build();
+
+ _windowSystem.AddWindow(contextWindow, activateWindow: true);
+ }
+
+ void RebuildRows()
+ {
+ if (_table is null)
+ {
+ return;
+ }
+
+ // Capture sort state before clearing rows.
+ var sortCol = _table.SortColumnIndex;
+ var sortDir = _table.CurrentSortDirection;
+
+ var selectedKey = SelectedItem is TItem sel ? GetKey(sel) : null;
+ var pageSize = PageSize;
+
+ // Sort the FULL filtered dataset first, then paginate.
+ // This guarantees consistent cross-page order regardless of column type.
+ var filtered = GetFiltered();
+ var sorted = ApplySort(filtered, sortCol, sortDir);
+ var totalPages = ComputeTotalPages(sorted.Count);
+
+ if (_pageIndex >= totalPages)
+ {
+ _pageIndex = Math.Max(0, totalPages - 1);
+ }
+
+ _table.ClearRows();
+
+ foreach (var item in sorted.Skip(_pageIndex * pageSize).Take(pageSize))
+ {
+ _table.AddRow(new UITableRow(BuildRow(item)) { Tag = item });
+ }
+
+ // Restore sort indicator on the column header. Rows are already in sorted order from
+ // ApplySort above; CustomRowComparer matches that comparer so SortByColumn produces
+ // an identity map — the visual indicator is set without re-ordering rows.
+ // ClearSort and SortByColumn do NOT fire PropertyChanged, so no recursion risk.
+ _table.ClearSort();
+ if (sortCol >= 0 && sortDir != SortDirection.None)
+ {
+ _table.SortByColumn(sortCol);
+ if (sortDir == SortDirection.Descending)
+ {
+ _table.SortByColumn(sortCol);
+ }
+ }
+ else if (DefaultSortColumn >= 0 && DefaultSortDirection != SortDirection.None && sortCol < 0)
+ {
+ _table.SortByColumn(DefaultSortColumn);
+ if (DefaultSortDirection == SortDirection.Descending)
+ {
+ _table.SortByColumn(DefaultSortColumn);
+ }
+ }
+
+ // Record the sort state we applied so OnTableMouseClick can detect real header-click changes.
+ _lastAppliedSortColumn = _table.SortColumnIndex;
+ _lastAppliedSortDirection = _table.CurrentSortDirection;
+
+ if (selectedKey is not null)
+ {
+ RestoreSelection(selectedKey);
+ }
+ else if (_table.Rows.Count > 0)
+ {
+ // ClearRows() resets SelectedRowIndex to -1. Pre-select the first row so
+ // SelectedItem is always non-null and ViewActions always returns actions.
+ _table.SelectedRowIndex = 0;
+ }
+
+ UpdatePageIndicator(sorted.Count, totalPages, pageSize);
+ RefreshDetail();
+ }
+
+ void UpdatePageIndicator(int totalItems, int totalPages, int pageSize)
+ {
+ if (_pageIndicator is not null)
+ {
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ _pageIndicator.Text = totalItems <= pageSize
+ ? $"[{mut}]{totalItems} item{(totalItems == 1 ? string.Empty : "s")}[/]"
+ : $"[{mut}]{(_pageIndex * pageSize) + 1}–{Math.Min((_pageIndex + 1) * pageSize, totalItems)} of {totalItems}[/]";
+ }
+
+ SetButtonEnabled(_prevPageButton, _pageIndex > 0);
+ SetButtonEnabled(_nextPageButton, _pageIndex < totalPages - 1);
+ }
+
+ void RestoreSelection(string key)
+ {
+ if (_table is null)
+ {
+ return;
+ }
+
+ // Iterate display positions to account for the active sort map.
+ var count = _table.Rows.Count;
+ for (var dispIdx = 0; dispIdx < count; dispIdx++)
+ {
+ _table.SelectedRowIndex = dispIdx;
+ if (_table.SelectedRow?.Tag is TItem item && GetKey(item) == key)
+ {
+ return;
+ }
+ }
+
+ if (count > 0)
+ {
+ _table.SelectedRowIndex = 0;
+ }
+ }
+
+ void RefreshDetail()
+ {
+ if (_detailPanel is null)
+ {
+ return;
+ }
+
+ _detailPanel.Content = RenderDetail(SelectedItem, _pendingData);
+ }
+
+ void ActivateSelected()
+ {
+ if (SelectedItem is TItem item)
+ {
+ OnRowActivated(item);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs
new file mode 100644
index 0000000..c85e021
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Controls;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Contract for a workbench view — a navigation-item content pane that can receive live data updates.
+///
+public interface IWorkbenchView : IDisposable
+{
+ ///
+ /// Gets or sets the callback invoked when the filter input gains or loses focus.
+ /// = filter focused; = filter unfocused.
+ /// Wired by MainWindow to gate global keyboard shortcuts.
+ /// Views that do not have a filter bar may leave this as a no-op.
+ ///
+ Action? OnFilterFocusChanged
+ {
+ get => null;
+ set { }
+ }
+
+ ///
+ /// Gets the primary focus target for this view (filter prompt or table).
+ /// Returns for views that do not support keyboard focus routing.
+ ///
+ IWindowControl? PrimaryFocusTarget => null;
+
+ ///
+ /// Gets or sets whether this view is currently visible to the user.
+ /// When , background data refreshes update the internal cache but do not
+ /// rebuild the table — preserving the user's sort order, selection, and scroll position.
+ ///
+ bool IsActive { get; set; }
+
+ ///
+ /// Gets the current content of the detail panel, or if no detail is shown.
+ /// Returns for views that do not have a detail panel.
+ ///
+ string? DetailContent => null;
+
+ ///
+ /// Gets the per-view help text shown in the help overlay — a brief description plus key shortcuts.
+ /// Returns an empty string when the view provides no extra help.
+ ///
+ string ViewHelp => string.Empty;
+
+ ///
+ /// Gets the actions available for the currently selected row.
+ /// Consumed by the keyboard dispatcher, right-click context menu, and the Actions menu bar item.
+ /// Returns an empty list when no item is selected or the view has no actions.
+ ///
+ IReadOnlyList ViewActions => [];
+
+ ///
+ /// Moves the selection one row toward the end of the table. No-op for views without a table.
+ ///
+ void MoveSelectionDown()
+ {
+ }
+
+ ///
+ /// Moves the selection one row toward the top of the table. No-op for views without a table.
+ ///
+ void MoveSelectionUp()
+ {
+ }
+
+ ///
+ /// Jumps the selection to the first row in the table. No-op for views without a table.
+ ///
+ void JumpToFirstRow()
+ {
+ }
+
+ ///
+ /// Jumps the selection to the last row in the table. No-op for views without a table.
+ ///
+ void JumpToLastRow()
+ {
+ }
+
+ ///
+ /// Toggles the detail pane open or closed with an animation.
+ /// Views without a detail pane may leave this as a no-op.
+ ///
+ void ToggleDetailPane()
+ {
+ }
+
+ ///
+ /// Builds the initial control hierarchy for this view. Called once during window construction.
+ ///
+ /// The SharpConsoleUI window system.
+ /// The root to embed in the navigation pane.
+ IWindowControl BuildContent(ConsoleWindowSystem windowSystem);
+
+ ///
+ /// Called on the background refresh thread whenever new data arrives.
+ /// Implementations must only update control properties — never create new controls here.
+ ///
+ /// The latest workbench data snapshot.
+ void UpdateData(WorkbenchData data);
+
+ ///
+ /// Activates the filter bar for this view, if the view supports filtering.
+ /// Default implementation is a no-op for views that do not have a filter bar.
+ ///
+ /// The host used to set focus.
+ void ActivateFilter(Window window)
+ {
+ }
+
+ ///
+ /// Clears the current filter and returns focus to the table. No-op for views without a filter.
+ ///
+ void ClearFilter()
+ {
+ }
+
+ ///
+ /// Applies the given filter string and rebuilds the table rows. No-op for views without a filter.
+ ///
+ /// The filter text to apply.
+ void SetFilter(string filter)
+ {
+ }
+
+ /// Advances to the next page of results. No-op for views without pagination.
+ void NextPage()
+ {
+ }
+
+ /// Goes back to the previous page of results. No-op for views without pagination.
+ void PreviousPage()
+ {
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs
new file mode 100644
index 0000000..f24da01
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Chronicle.Contracts.Identities;
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Identities navigation item — filterable table of known identities with a detail pane.
+///
+public class IdentitiesView : FilterableTableView
+{
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Name", TextJustification.Left, null),
+ ("UserName", TextJustification.Left, 30),
+ ("Subject", TextJustification.Left, 40)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "IDENTITY";
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.Identities.OrderBy(i => i.Name);
+
+ ///
+ protected override string GetKey(Identity item) => item.Subject;
+
+ ///
+ protected override string[] BuildRow(Identity item) =>
+ [item.Name, item.UserName, item.Subject];
+
+ ///
+ protected override string RenderDetail(Identity? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an identity.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+
+ return string.Join(
+ "\n",
+ $"[{mut}]Subject[/] {item.Subject}",
+ $"[{mut}]Name[/] {item.Name}",
+ $"[{mut}]UserName[/] {item.UserName}");
+ }
+
+ ///
+ protected override bool MatchesFilter(Identity item, string filter) =>
+ item.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.UserName.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Subject.Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs
new file mode 100644
index 0000000..69e1817
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs
@@ -0,0 +1,185 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Chronicle.Contracts.Jobs;
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Jobs tab — filterable table of background jobs with stop/resume actions in the detail pane.
+///
+public class JobsView : FilterableTableView
+{
+ /// Gets the currently selected job, or if none is selected.
+ public Job? SelectedJob => SelectedItem;
+
+ ///
+ public override string ViewHelp =>
+ "Lists all background jobs and their current status.\n" +
+ " [S] Stop the selected job\n" +
+ " [U] Resume a stopped job\n" +
+ " [Space] Check / uncheck row for bulk operations\n" +
+ " [S] / [U] (with 2+ checked) Bulk stop / resume all checked jobs";
+
+ ///
+ /// Gets or sets the callback invoked when the user requests to stop a job.
+ ///
+ public Action? OnStopJob { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests to resume a job.
+ ///
+ public Action? OnResumeJob { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk stop of all checked jobs.
+ ///
+ public Action>? OnStopAll { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk resume of all checked jobs.
+ ///
+ public Action>? OnResumeAll { get; set; }
+
+ ///
+ /// Gets all jobs that are currently checked (checkbox mode).
+ ///
+ public IReadOnlyList Checked => CheckedItems;
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Status", TextJustification.Left, 22),
+ ("Type", TextJustification.Left, null),
+ ("Progress", TextJustification.Right, 14)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "JOB";
+
+ ///
+ protected override bool HasCheckboxMode => true;
+
+ ///
+ protected override IReadOnlyList GetAvailableActions(Job item)
+ {
+ List actions = [];
+ if (OnStopJob is not null)
+ {
+ actions.Add(new ViewAction("Stop job", "S", ConsoleKey.S, default, () => OnStopJob(item)));
+ }
+
+ if (OnResumeJob is not null)
+ {
+ actions.Add(new ViewAction("Resume job", "U", ConsoleKey.U, default, () => OnResumeJob(item)));
+ }
+
+ var checkedItems = Checked;
+ if (OnStopAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Stop {checkedItems.Count} checked", null, null, default, () => OnStopAll(checkedItems)));
+ }
+
+ if (OnResumeAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Resume {checkedItems.Count} checked", null, null, default, () => OnResumeAll(checkedItems)));
+ }
+
+ return actions;
+ }
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.Jobs.OrderBy(j => j.Status.ToString());
+
+ ///
+ protected override string GetKey(Job item) => item.Id.ToString();
+
+ ///
+ protected override string[] BuildRow(Job item)
+ {
+ var statusColor = GetJobStatusColor(item.Status);
+ return
+ [
+ $"[{statusColor}]{item.Status}[/]",
+ item.Type ?? item.Id.ToString(),
+ FormatProgress(item.Progress)
+ ];
+ }
+
+ ///
+ protected override string RenderDetail(Job? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var statusColor = GetJobStatusColor(item.Status);
+
+ var lines = new List
+ {
+ $"[{mut}]Id[/] {item.Id}",
+ $"[{mut}]Type[/] {item.Type ?? "—"}",
+ $"[{mut}]Status[/] [{statusColor}]{item.Status}[/]",
+ $"[{mut}]Progress[/] {FormatProgress(item.Progress)}"
+ };
+
+ if (item.Progress is not null)
+ {
+ lines.Add($"[{mut}]Steps[/] {item.Progress.SuccessfulSteps}/{item.Progress.TotalSteps}");
+ if (item.Progress.FailedSteps > 0)
+ {
+ lines.Add($"[{WorkbenchColors.Danger.ToMarkup()}]Failed[/] {item.Progress.FailedSteps}");
+ }
+
+ if (!string.IsNullOrEmpty(item.Progress.Message))
+ {
+ lines.Add($"[{mut}]Message[/] {item.Progress.Message}");
+ }
+ }
+
+ if (OnStopJob is not null || OnResumeJob is not null)
+ {
+ lines.Add(string.Empty);
+ if (OnStopJob is not null)
+ {
+ lines.Add($"[{mut}]Press[/] [bold]S[/] [{mut}]to stop[/]");
+ }
+
+ if (OnResumeJob is not null)
+ {
+ lines.Add($"[{mut}]Press[/] [bold]U[/] [{mut}]to resume[/]");
+ }
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ ///
+ protected override bool MatchesFilter(Job item, string filter) =>
+ (item.Type ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Status.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase);
+
+ static string GetJobStatusColor(JobStatus status) => status switch
+ {
+ JobStatus.Running => WorkbenchColors.Success.ToMarkup(),
+ JobStatus.Failed => WorkbenchColors.Danger.ToMarkup(),
+ JobStatus.Stopped => WorkbenchColors.Warning.ToMarkup(),
+ JobStatus.CompletedWithFailures => WorkbenchColors.Warning.ToMarkup(),
+ _ => WorkbenchColors.Muted.ToMarkup()
+ };
+
+ static string FormatProgress(JobProgress? p)
+ {
+ if (p is null || p.TotalSteps == 0)
+ {
+ return "—";
+ }
+
+ return $"{p.SuccessfulSteps}/{p.TotalSteps}";
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs
new file mode 100644
index 0000000..3c9af78
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text;
+using System.Text.Json;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Formats JSON strings as human-readable YAML-style markup for display in workbench detail panes.
+///
+static class JsonYamlFormatter
+{
+ ///
+ /// Formats a JSON string as an indented, human-readable YAML-style representation.
+ /// Falls back to wrapping the raw string in muted markup when parsing fails.
+ ///
+ /// The JSON string to format.
+ /// The SharpConsoleUI markup tag applied to property key labels.
+ /// A formatted markup string.
+ internal static string FormatAsYaml(string json, string mutedMarkup)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return $"[{mutedMarkup}](empty)[/]";
+ }
+
+ try
+ {
+ var doc = JsonDocument.Parse(json);
+ var sb = new StringBuilder();
+ AppendElement(doc.RootElement, sb, 0, mutedMarkup);
+ return sb.ToString().TrimEnd();
+ }
+ catch
+ {
+ return $"[{mutedMarkup}]{json}[/]";
+ }
+ }
+
+ static void AppendElement(JsonElement element, StringBuilder sb, int indent, string mut)
+ {
+ var pad = new string(' ', indent * 2);
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (var prop in element.EnumerateObject())
+ {
+ // Skip JSON Schema noise that clutters the display without adding value.
+ if (IsJsonSchemaNoise(prop.Name))
+ {
+ continue;
+ }
+
+ if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
+ {
+ sb.AppendLine($"{pad}[{mut}]{prop.Name}:[/]");
+ AppendElement(prop.Value, sb, indent + 1, mut);
+ }
+ else
+ {
+ sb.AppendLine($"{pad}[{mut}]{prop.Name}:[/] {ScalarText(prop.Value)}");
+ }
+ }
+
+ break;
+
+ case JsonValueKind.Array:
+ foreach (var item in element.EnumerateArray())
+ {
+ if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
+ {
+ sb.AppendLine($"{pad}-");
+ AppendElement(item, sb, indent + 1, mut);
+ }
+ else
+ {
+ sb.AppendLine($"{pad}- {ScalarText(item)}");
+ }
+ }
+
+ break;
+
+ default:
+ sb.AppendLine($"{pad}{ScalarText(element)}");
+ break;
+ }
+ }
+
+ static bool IsJsonSchemaNoise(string key) =>
+ string.Equals(key, "required", StringComparison.Ordinal) ||
+ string.Equals(key, "$schema", StringComparison.Ordinal) ||
+ string.Equals(key, "definitions", StringComparison.Ordinal) ||
+ string.Equals(key, "$defs", StringComparison.Ordinal);
+
+ static string ScalarText(JsonElement element) => element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString() ?? string.Empty,
+ JsonValueKind.Null => "~",
+ _ => element.GetRawText()
+ };
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs
new file mode 100644
index 0000000..a98677a
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs
@@ -0,0 +1,131 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using UITableRow = SharpConsoleUI.Controls.TableRow;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Namespaces tab — list of available namespaces in the current event store with a Switch action.
+/// Selecting an entry and pressing Enter switches the active namespace.
+///
+public class NamespacesView : IWorkbenchView
+{
+ TableControl? _table;
+ MarkupControl? _helpPane;
+ WorkbenchData? _pendingData;
+
+ ///
+ public bool IsActive { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user switches to a different namespace.
+ ///
+ public Action? OnSwitch { get; set; }
+
+ ///
+ public void Dispose()
+ {
+ _table?.Dispose();
+ _helpPane?.Dispose();
+ }
+
+ ///
+ public IWindowControl BuildContent(ConsoleWindowSystem windowSystem)
+ {
+ _table = Controls.Table()
+ .AddColumn("Namespace", SharpConsoleUI.Layout.TextJustification.Left, null)
+ .Interactive()
+ .WithFiltering()
+ .WithSorting()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .OnRowActivated((_, _) => SwitchToSelected())
+ .WithName("NamespacesTable")
+ .Build();
+
+ _helpPane = new MarkupControl(
+ [
+ $"[{WorkbenchColors.Accent.ToMarkup()}][bold]SWITCH NAMESPACE[/][/]",
+ string.Empty,
+ $" [{WorkbenchColors.Muted.ToMarkup()}]Select a namespace and press[/] [bold]Enter[/] [{WorkbenchColors.Muted.ToMarkup()}]to switch.[/]"
+ ])
+ { Name = "NamespacesHelp" };
+
+ var root = HorizontalGridControl.Create()
+ .Column(c => c.Add(_table))
+ .WithSplitterAfter(0)
+ .Column(c => c.Width(44).Add(_helpPane))
+ .Build();
+
+ // Apply any data that arrived before controls were ready (NavigationView lazy init).
+ if (_pendingData is not null)
+ UpdateData(_pendingData);
+
+ return root;
+ }
+
+ ///
+ public void UpdateData(WorkbenchData data)
+ {
+ _pendingData = data;
+ if (_table is null) return;
+
+ var selectedKey = _table.SelectedRow?.Tag as string;
+
+ _table.ClearRows();
+ foreach (var name in data.NamespaceNames.Order())
+ {
+ _table.AddRow(new UITableRow([name]) { Tag = name });
+ }
+
+ if (selectedKey is not null)
+ {
+ RestoreSelection(selectedKey);
+ }
+
+ if (_helpPane is null) return;
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+
+ var lines = new List
+ {
+ $"[{acc}][bold]SWITCH NAMESPACE[/][/]",
+ string.Empty,
+ $"[{mut}]Active[/] [{suc}]{data.Namespace}[/]",
+ $"[{mut}]Store[/] [{mut}]{data.EventStore}[/]",
+ $"[{mut}]Available[/] {data.NamespaceNames.Count}",
+ string.Empty,
+ $" [{mut}]Select a namespace and press[/] [bold]Enter[/]",
+ $" [{mut}]to make it the active namespace.[/]"
+ };
+
+ _helpPane.Text = string.Join('\n', lines);
+ }
+
+ void RestoreSelection(string key)
+ {
+ if (_table is null) return;
+
+ for (var i = 0; i < _table.Rows.Count; i++)
+ {
+ if (_table.Rows[i].Tag is string name && name == key)
+ {
+ _table.SelectedRowIndex = i;
+ return;
+ }
+ }
+ }
+
+ void SwitchToSelected()
+ {
+ if (_table?.SelectedRow?.Tag is string nsName)
+ {
+ OnSwitch?.Invoke(nsName);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs
new file mode 100644
index 0000000..9cf3725
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs
@@ -0,0 +1,195 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Observers navigation item — sortable, filterable table with a detail pane showing observer state and event types.
+///
+public class ObserversView : FilterableTableView
+{
+ /// Gets the currently selected observer, or if none is selected.
+ public ObserverInformation? SelectedObserver => SelectedItem;
+
+ ///
+ public override string ViewHelp =>
+ "Lists all registered observers and their current running state.\n" +
+ " [R] Replay the selected observer from the beginning\n" +
+ " [Space] Check / uncheck row for bulk operations\n" +
+ " [R] (with 2+ checked) Replay all checked observers";
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a replay of the selected observer.
+ ///
+ public Action? OnReplay { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk replay of all checked observers.
+ ///
+ public Action>? OnReplayAll { get; set; }
+
+ ///
+ /// Gets all observers that are currently checked (checkbox mode).
+ ///
+ public IReadOnlyList Checked => CheckedItems;
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("State", TextJustification.Left, 18),
+ ("Id", TextJustification.Left, null),
+ ("Type", TextJustification.Left, 16)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "OBSERVER";
+
+ /// Uses the warning amber to match the OBSERVATION section color.
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Warning;
+
+ ///
+ protected override bool HasCheckboxMode => true;
+
+ ///
+ protected override IReadOnlyList GetAvailableActions(ObserverInformation item)
+ {
+ List actions = [];
+ if (OnReplay is not null)
+ {
+ actions.Add(new ViewAction("Replay observer", "R", ConsoleKey.R, default, () => OnReplay(item)));
+ }
+
+ var checkedItems = Checked;
+ if (OnReplayAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Replay {checkedItems.Count} checked", null, null, default, () => OnReplayAll(checkedItems)));
+ }
+
+ return actions;
+ }
+
+ ///
+ protected override IComparer GetColumnComparer(int columnIndex) => columnIndex switch
+ {
+ 0 => Comparer.Create((a, b) => ObserverSortOrder(a).CompareTo(ObserverSortOrder(b))),
+ _ => base.GetColumnComparer(columnIndex)
+ };
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.Observers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id);
+
+ ///
+ protected override string GetKey(ObserverInformation item) => item.Id;
+
+ ///
+ protected override string[] BuildRow(ObserverInformation item)
+ {
+ var stateColor = GetObserverStateColor(item);
+ var icon = GetObserverIcon(item);
+
+ return
+ [
+ $"[{stateColor}]{icon} {item.RunningState}[/]",
+ item.Id,
+ item.Type.ToString()
+ ];
+ }
+
+ ///
+ protected override string RenderDetail(ObserverInformation? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var stateColor = GetObserverStateColor(item);
+
+ var lines = new List
+ {
+ $"[{mut}]Id[/] {item.Id}",
+ $"[{mut}]Type[/] {item.Type}",
+ $"[{mut}]State[/] [{stateColor}]{item.RunningState}[/]",
+ $"[{mut}]Last seq[/] {(item.LastHandledEventSequenceNumber == ulong.MaxValue ? "N/A" : item.LastHandledEventSequenceNumber.ToString("N0"))}",
+ string.Empty,
+ $"[{mut}]Event Types:[/]"
+ };
+
+ foreach (var et in (item.EventTypes ?? []).OrderBy(e => e.Id))
+ {
+ lines.Add($" • {et.Id} gen {et.Generation}");
+ }
+
+ if (OnReplay is not null)
+ {
+ lines.Add(string.Empty);
+ lines.Add($"[{mut}]Press[/] [bold]R[/] [{mut}]to replay[/]");
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ ///
+ protected override bool MatchesFilter(ObserverInformation item, string filter)
+ {
+ if (filter.StartsWith("state:", StringComparison.OrdinalIgnoreCase))
+ {
+ return item.RunningState.ToString().Contains(filter[6..], StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (filter.StartsWith("type:", StringComparison.OrdinalIgnoreCase))
+ {
+ return item.Type.ToString().Contains(filter[5..], StringComparison.OrdinalIgnoreCase);
+ }
+
+ // event:TypeId — match observers that subscribe to the given event type ID
+ if (filter.StartsWith("event:", StringComparison.OrdinalIgnoreCase))
+ {
+ var eventTypeId = filter[6..];
+ return (item.EventTypes ?? []).Any(et => et.Id.Contains(eventTypeId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ return item.Id.Contains(filter, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ protected override IEnumerable GetCompletions(string input) =>
+ [
+ "state:active",
+ "state:replaying",
+ "state:disconnected",
+ "state:suspended",
+ "type:projection",
+ "type:reducer",
+ "type:reactor"
+ ];
+
+ static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch
+ {
+ ObserverRunningState.Disconnected => 0,
+ ObserverRunningState.Replaying => 1,
+ ObserverRunningState.Active => 2,
+ ObserverRunningState.Suspended => 3,
+ _ => 4
+ };
+
+ static string GetObserverStateColor(ObserverInformation obs) => obs.RunningState switch
+ {
+ ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(),
+ ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(),
+ ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(),
+ _ => WorkbenchColors.Muted.ToMarkup()
+ };
+
+ static string GetObserverIcon(ObserverInformation obs) => obs.RunningState switch
+ {
+ ObserverRunningState.Active => "●",
+ ObserverRunningState.Replaying => "▲",
+ ObserverRunningState.Disconnected => "⊘",
+ _ => "○"
+ };
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs
new file mode 100644
index 0000000..8e47178
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs
@@ -0,0 +1,179 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Overview tab — server health card, observer stats, and attention items (failures + recommendations),
+/// laid out as three bordered rounded panels side by side.
+///
+public class OverviewView : IWorkbenchView
+{
+ readonly Queue _observerHistory = new(capacity: 10);
+ PanelControl? _healthPanel;
+ PanelControl? _observerPanel;
+ PanelControl? _attentionPanel;
+ SparklineControl? _observerSparkline;
+ WorkbenchData? _pendingData;
+
+ ///
+ public bool IsActive { get; set; }
+
+ ///
+ public void Dispose()
+ {
+ _healthPanel?.Dispose();
+ _observerPanel?.Dispose();
+ _attentionPanel?.Dispose();
+ _observerSparkline?.Dispose();
+ }
+
+ ///
+ public IWindowControl BuildContent(ConsoleWindowSystem windowSystem)
+ {
+ _healthPanel = Controls.Panel()
+ .WithContent("Loading...")
+ .WithHeader(" SERVER HEALTH ")
+ .Rounded()
+ .WithBorderColor(WorkbenchColors.Accent)
+ .WithPadding(1, 0, 1, 0)
+ .FillVertical()
+ .WithName("OverviewHealthPanel")
+ .Build();
+
+ _observerSparkline = new SparklineBuilder()
+ .WithHeight(3)
+ .WithBarColor(WorkbenchColors.Accent)
+ .WithTitle("observer count history", WorkbenchColors.Muted)
+ .WithData([0])
+ .Build();
+
+ _observerPanel = Controls.Panel()
+ .WithContent("Loading...")
+ .WithHeader(" OBSERVERS ")
+ .Rounded()
+ .WithBorderColor(WorkbenchColors.Accent)
+ .WithPadding(1, 0, 1, 0)
+ .FillVertical()
+ .WithName("OverviewObserversPanel")
+ .Build();
+
+ _attentionPanel = Controls.Panel()
+ .WithContent("Loading...")
+ .WithHeader(" ATTENTION ")
+ .Rounded()
+ .WithBorderColor(WorkbenchColors.Accent)
+ .WithPadding(1, 0, 1, 0)
+ .FillVertical()
+ .WithName("OverviewAttentionPanel")
+ .Build();
+
+ var root = Controls.HorizontalGrid()
+ .Column(col => col.Add(_healthPanel))
+ .Column(col => col.Add(_observerPanel).Add(_observerSparkline))
+ .Column(col => col.Add(_attentionPanel))
+ .Build();
+
+ // Apply any data that arrived before controls were ready (NavigationView lazy init).
+ if (_pendingData is not null)
+ UpdateData(_pendingData);
+
+ return root;
+ }
+
+ ///
+ public void UpdateData(WorkbenchData data)
+ {
+ _pendingData = data;
+ if (_healthPanel is null) return;
+
+ var suc = WorkbenchColors.Success.ToMarkup();
+ var dan = WorkbenchColors.Danger.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var war = WorkbenchColors.Warning.ToMarkup();
+
+ var connStatus = data.IsConnected
+ ? $"[{suc}]● Connected[/]"
+ : $"[{dan}]● Disconnected[/]";
+ var version = data.ServerVersion is not null
+ ? $"[{mut}]v{data.ServerVersion}[/]"
+ : $"[{mut}]unknown[/]";
+ var seq = data.TailSequenceNumber.HasValue
+ ? $"[bold]#{data.TailSequenceNumber.Value:N0}[/]"
+ : $"[{mut}]—[/]";
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ _healthPanel.Content =
+ "[bold]CONTEXT[/]\n" +
+ $" [{mut}]Store[/] [{acc}]{data.EventStore}[/]\n" +
+ $" [{mut}]Namespace[/] [{acc}]{data.Namespace}[/]\n" +
+ "\n" +
+ $"[{mut}]Status[/] {connStatus}\n" +
+ $"[{mut}]Version[/] {version}\n" +
+ $"[{mut}]Tail seq[/] {seq}\n" +
+ $"[{mut}]Server[/] [{mut}]{data.ConnectionString}[/]";
+
+ string obsColor;
+ if (data.DisconnectedObservers > 0) obsColor = dan;
+ else if (data.ReplayingObservers > 0) obsColor = war;
+ else obsColor = suc;
+
+ _observerPanel!.Content =
+ $"[{suc}]●[/] Active [bold]{data.ActiveObservers}[/]\n" +
+ $"[{war}]▲[/] Replaying [bold]{data.ReplayingObservers}[/]\n" +
+ $"[{mut}]○[/] Suspended [bold]{data.SuspendedObservers}[/]\n" +
+ $"[{dan}]⊘[/] Disconnected [bold]{data.DisconnectedObservers}[/]\n" +
+ $"[{mut}]━[/] Total [{obsColor}][bold]{data.Observers.Count}[/][/]";
+
+ UpdateObserverSparkline(data.Observers.Count);
+
+ var hasAttention = data.FailedPartitions.Count > 0 || data.Recommendations.Count > 0;
+ if (hasAttention)
+ {
+ _attentionPanel!.BorderColor = WorkbenchColors.Warning;
+ }
+ else
+ {
+ _attentionPanel!.BorderColor = WorkbenchColors.Accent;
+ }
+
+ var attentionLines = new List();
+
+ if (data.FailedPartitions.Count > 0)
+ {
+ attentionLines.Add($"[{dan}]⚠[/] [bold]{data.FailedPartitions.Count}[/] failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")} [{mut}]→ press 3[/]");
+ }
+ else
+ {
+ attentionLines.Add($"[{suc}]✓[/] No failed partitions");
+ }
+
+ if (data.Recommendations.Count > 0)
+ {
+ attentionLines.Add($"[{war}]![/] [bold]{data.Recommendations.Count}[/] pending recommendation{(data.Recommendations.Count == 1 ? string.Empty : "s")} [{mut}]→ press 5[/]");
+ }
+ else
+ {
+ attentionLines.Add($"[{suc}]✓[/] No pending recommendations");
+ }
+
+ _attentionPanel!.Content = string.Join('\n', attentionLines);
+ }
+
+ void UpdateObserverSparkline(int totalObservers)
+ {
+ if (_observerSparkline is null) return;
+
+ _observerHistory.Enqueue(totalObservers);
+ while (_observerHistory.Count > 10)
+ {
+ _observerHistory.Dequeue();
+ }
+
+ _observerSparkline.SetDataPoints(_observerHistory);
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs
new file mode 100644
index 0000000..dc07e7b
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Projections navigation item — filterable table of projection definitions with declaration preview in the detail pane.
+///
+public class ProjectionsView : FilterableTableView
+{
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Identifier", TextJustification.Left, null),
+ ("Read Model", TextJustification.Left, 35)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "PROJECTION";
+
+ ///
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Mauve;
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.ProjectionDefinitions.OrderBy(d => d.Identifier);
+
+ ///
+ protected override string GetKey(ProjectionDefinition item) => item.Identifier;
+
+ ///
+ protected override string[] BuildRow(ProjectionDefinition item) =>
+ [item.Identifier, item.ReadModel ?? string.Empty];
+
+ ///
+ protected override string RenderDetail(ProjectionDefinition? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]";
+ }
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+
+ var lines = new List
+ {
+ $"[{acc}][bold]PROJECTION[/][/]",
+ string.Empty,
+ $" [{mut}]Identifier[/] {item.Identifier}",
+ $" [{mut}]Read Model[/] {item.ReadModel ?? "—"}",
+ $" [{mut}]Active[/] [{(item.IsActive ? suc : mut)}]{(item.IsActive ? "Yes" : "No")}[/]",
+ $" [{mut}]Rewindable[/] [{(item.IsRewindable ? suc : mut)}]{(item.IsRewindable ? "Yes" : "No")}[/]",
+ string.Empty,
+ $" [{acc}]Declaration (preview):[/]"
+ };
+
+ var declarations = data?.ProjectionDeclarations ?? new Dictionary();
+ if (declarations.TryGetValue(item.Identifier, out var declaration) && !string.IsNullOrEmpty(declaration))
+ {
+ foreach (var line in declaration.Split('\n').Take(40))
+ {
+ lines.Add($" [{mut}]{line.TrimEnd()}[/]");
+ }
+ }
+ else
+ {
+ lines.Add($" [{mut}](no declaration available)[/]");
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ ///
+ protected override bool MatchesFilter(ProjectionDefinition item, string filter) =>
+ item.Identifier.Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs
new file mode 100644
index 0000000..b304829
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs
@@ -0,0 +1,194 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Read Models navigation item — filterable table of read model definitions with metadata in the detail pane.
+/// Pressing Enter on a selected row opens a detail overlay showing definition info and live instances.
+///
+public class ReadModelsView : FilterableTableView
+{
+ ConsoleWindowSystem? _windowSystem;
+
+ ///
+ /// Gets or sets the callback invoked when the user activates a read model row (Enter).
+ /// Receives the container name and a cancellation token; returns a
+ /// snapshot with read model instances populated.
+ ///
+ public Func>? OnFetchInstances { get; set; }
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Container", TextJustification.Left, null),
+ ("Owner", TextJustification.Left, 12),
+ ("Source", TextJustification.Left, 14)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "READ MODEL";
+
+ ///
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Mauve;
+
+ ///
+ public override IWindowControl BuildContent(ConsoleWindowSystem windowSystem)
+ {
+ _windowSystem = windowSystem;
+ return base.BuildContent(windowSystem);
+ }
+
+ ///
+ /// Opens a detail overlay for the currently selected read model row, if any.
+ ///
+ public void OpenSelectedDetailOverlay()
+ {
+ if (SelectedItem is WorkbenchReadModel rm)
+ {
+ OpenDetailOverlay(rm);
+ }
+ }
+
+ ///
+ /// Opens a detail overlay for the given read model, fetching instances if is wired.
+ ///
+ /// The read model to display.
+ public void OpenDetailOverlay(WorkbenchReadModel rm)
+ {
+ if (_windowSystem is null)
+ {
+ return;
+ }
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+ var queryableColor = rm.IsQueryable ? suc : mut;
+
+ var infoContent = string.Join(
+ "\n",
+ $"[{acc}][bold]{rm.ContainerName}[/][/]",
+ string.Empty,
+ $"[{mut}]Container[/] {rm.ContainerName}",
+ $"[{mut}]Display Name[/] {rm.DisplayName}",
+ $"[{mut}]Owner[/] {rm.Owner}",
+ $"[{mut}]Source[/] {rm.Source}",
+ $"[{mut}]Queryable[/] [{queryableColor}]{(rm.IsQueryable ? "Yes" : "No")}[/]",
+ $"[{mut}]Identifier[/] {rm.Identifier}");
+
+ var instancesContent = BuildInstancesContent(rm);
+
+ List<(string TabName, string Content)> tabs =
+ [
+ ("Info", infoContent),
+ ("Instances", instancesContent)
+ ];
+
+ var overlay = new DetailOverlayWindow();
+ var window = overlay.Build(_windowSystem, $" {rm.ContainerName} ", tabs, []);
+ _windowSystem.AddWindow(window, activateWindow: true);
+ }
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.ReadModelDefinitions.OrderBy(d => d.ContainerName);
+
+ ///
+ protected override string GetKey(WorkbenchReadModel item) => item.ContainerName;
+
+ ///
+ protected override string[] BuildRow(WorkbenchReadModel item) =>
+ [item.ContainerName, item.Owner, item.Source];
+
+ ///
+ protected override string RenderDetail(WorkbenchReadModel? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]";
+ }
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+ var queryableColor = item.IsQueryable ? suc : mut;
+
+ return string.Join(
+ "\n",
+ $"[{acc}][bold]READ MODEL[/][/]",
+ string.Empty,
+ $" [{mut}]Container[/] {item.ContainerName}",
+ $" [{mut}]Display Name[/] {item.DisplayName}",
+ $" [{mut}]Owner[/] {item.Owner}",
+ $" [{mut}]Source[/] {item.Source}",
+ $" [{mut}]Queryable[/] [{queryableColor}]{(item.IsQueryable ? "Yes" : "No")}[/]",
+ $" [{mut}]Identifier[/] {item.Identifier}",
+ string.Empty,
+ $"[{mut}]Press[/] [bold]Enter[/] [{mut}]to view instances[/]");
+ }
+
+ ///
+ protected override bool MatchesFilter(WorkbenchReadModel item, string filter)
+ {
+ if (filter.StartsWith("owner:", StringComparison.OrdinalIgnoreCase))
+ {
+ return item.Owner.Contains(filter[6..], StringComparison.OrdinalIgnoreCase);
+ }
+
+ return item.ContainerName.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ protected override IEnumerable GetCompletions(string input) =>
+ [
+ "owner:client",
+ "owner:server"
+ ];
+
+ ///
+ protected override void OnRowActivated(WorkbenchReadModel item) => OpenDetailOverlay(item);
+
+ string BuildInstancesContent(WorkbenchReadModel rm)
+ {
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var dan = WorkbenchColors.Danger.ToMarkup();
+
+ if (OnFetchInstances is null)
+ {
+ return $"[{mut}](No instance loader configured)[/]";
+ }
+
+ try
+ {
+ var data = OnFetchInstances(rm.ContainerName, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ if (data.ReadModelInstancesError is not null)
+ {
+ return $"[{dan}]Error: {data.ReadModelInstancesError}[/]";
+ }
+
+ if (data.ReadModelInstances.Count == 0)
+ {
+ return $"[{mut}](No instances found)[/]";
+ }
+
+ var separator = $"\n[{mut}]{new string('─', 60)}[/]\n";
+ var total = data.ReadModelInstancesTotalCount;
+ var shown = data.ReadModelInstances.Count;
+ var header = $"[{mut}]Showing {shown} of {total} instance{(total == 1 ? string.Empty : "s")}[/]\n";
+
+ return header + separator + string.Join(separator, data.ReadModelInstances);
+ }
+ catch (Exception ex)
+ {
+ return $"[{dan}]Error fetching instances: {ex.Message}[/]";
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs
new file mode 100644
index 0000000..67a17a6
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs
@@ -0,0 +1,145 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Recommendations tab — filterable table of pending recommendations with apply/ignore actions.
+///
+public class RecommendationsView : FilterableTableView
+{
+ /// Gets the currently selected recommendation, or if none is selected.
+ public Recommendation? SelectedRecommendation => SelectedItem;
+
+ ///
+ public override string ViewHelp =>
+ "Lists pending recommendations suggested by Chronicle.\n" +
+ " [A] Apply the selected recommendation\n" +
+ " [I] Ignore the selected recommendation\n" +
+ " [Space] Check / uncheck row for bulk operations\n" +
+ " [A] / [I] (with 2+ checked) Bulk apply / ignore all checked";
+
+ ///
+ /// Gets or sets the callback invoked when the user applies a recommendation.
+ ///
+ public Action? OnApply { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user ignores a recommendation.
+ ///
+ public Action? OnIgnore { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk apply of all checked recommendations.
+ ///
+ public Action>? OnApplyAll { get; set; }
+
+ ///
+ /// Gets or sets the callback invoked when the user requests a bulk ignore of all checked recommendations.
+ ///
+ public Action>? OnIgnoreAll { get; set; }
+
+ ///
+ /// Gets all recommendations that are currently checked (checkbox mode).
+ ///
+ public IReadOnlyList Checked => CheckedItems;
+
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Name", TextJustification.Left, null),
+ ("Type", TextJustification.Left, 20)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "RECOMMENDATION";
+
+ ///
+ protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Warning;
+
+ ///
+ protected override bool HasCheckboxMode => true;
+
+ ///
+ protected override IReadOnlyList GetAvailableActions(Recommendation item)
+ {
+ List actions = [];
+ if (OnApply is not null)
+ {
+ actions.Add(new ViewAction("Apply recommendation", "A", ConsoleKey.A, default, () => OnApply(item)));
+ }
+
+ if (OnIgnore is not null)
+ {
+ actions.Add(new ViewAction("Ignore recommendation", "I", ConsoleKey.I, default, () => OnIgnore(item)));
+ }
+
+ var checkedItems = Checked;
+ if (OnApplyAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Apply {checkedItems.Count} checked", null, null, default, () => OnApplyAll(checkedItems)));
+ }
+
+ if (OnIgnoreAll is not null && checkedItems.Count > 1)
+ {
+ actions.Add(new ViewAction($"Ignore {checkedItems.Count} checked", null, null, default, () => OnIgnoreAll(checkedItems)));
+ }
+
+ return actions;
+ }
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) => data.Recommendations;
+
+ ///
+ protected override string GetKey(Recommendation item) => item.Id.ToString();
+
+ ///
+ protected override string[] BuildRow(Recommendation item) =>
+ [item.Name ?? item.Id.ToString(), item.Type ?? "—"];
+
+ ///
+ protected override string RenderDetail(Recommendation? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+
+ var lines = new List
+ {
+ $"[{mut}]Name[/] {item.Name ?? item.Id.ToString()}",
+ $"[{mut}]Type[/] {item.Type ?? "—"}"
+ };
+
+ if (!string.IsNullOrEmpty(item.Description))
+ {
+ lines.Add(string.Empty);
+ lines.Add($"[{mut}]Description:[/]");
+ lines.Add($" {item.Description}");
+ }
+
+ lines.Add(string.Empty);
+ if (OnApply is not null)
+ {
+ lines.Add($"[{mut}]Press[/] [bold]A[/] [{mut}]to apply[/]");
+ }
+
+ if (OnIgnore is not null)
+ {
+ lines.Add($"[{mut}]Press[/] [bold]I[/] [{mut}]to ignore[/]");
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ ///
+ protected override bool MatchesFilter(Recommendation item, string filter) =>
+ (item.Name ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ (item.Type ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs
new file mode 100644
index 0000000..b6ef277
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions;
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Subscriptions navigation item — filterable table of event store subscriptions with a detail pane.
+///
+public class SubscriptionsView : FilterableTableView
+{
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Identifier", TextJustification.Left, null),
+ ("Source Store", TextJustification.Left, 30),
+ ("Event Types", TextJustification.Right, 12)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "SUBSCRIPTION";
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.EventStoreSubscriptions.OrderBy(s => s.Identifier);
+
+ ///
+ protected override string GetKey(EventStoreSubscriptionDefinition item) => item.Identifier;
+
+ ///
+ protected override string[] BuildRow(EventStoreSubscriptionDefinition item) =>
+ [
+ item.Identifier,
+ item.SourceEventStore,
+ (item.EventTypes?.Count ?? 0).ToString()
+ ];
+
+ ///
+ protected override string RenderDetail(EventStoreSubscriptionDefinition? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a subscription.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var acc = WorkbenchColors.Accent.ToMarkup();
+
+ var lines = new List
+ {
+ $"[{mut}]Identifier[/] {item.Identifier}",
+ $"[{mut}]Source Store[/] {item.SourceEventStore}",
+ string.Empty,
+ $"[{acc}]Event Types:[/]"
+ };
+
+ foreach (var et in item.EventTypes ?? [])
+ {
+ lines.Add($" • {et.Id}");
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ ///
+ protected override bool MatchesFilter(EventStoreSubscriptionDefinition item, string filter) =>
+ item.Identifier.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.SourceEventStore.Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs
new file mode 100644
index 0000000..51c2936
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI.Layout;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Users navigation item — filterable table of registered users with a detail pane.
+///
+public class UsersView : FilterableTableView
+{
+ ///
+ protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns =>
+ [
+ ("Username", TextJustification.Left, null),
+ ("Email", TextJustification.Left, 30),
+ ("Active", TextJustification.Left, 8)
+ ];
+
+ ///
+ protected override string DetailPanelHeader => "USER";
+
+ ///
+ protected override IEnumerable GetItems(WorkbenchData data) =>
+ data.Users.OrderBy(u => u.Username);
+
+ ///
+ protected override string GetKey(User item) => item.Id.ToString();
+
+ ///
+ protected override string[] BuildRow(User item)
+ {
+ var activeColor = item.IsActive ? WorkbenchColors.Success.ToMarkup() : WorkbenchColors.Muted.ToMarkup();
+ return
+ [
+ item.Username,
+ item.Email ?? string.Empty,
+ $"[{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]"
+ ];
+ }
+
+ ///
+ protected override string RenderDetail(User? item, WorkbenchData? data)
+ {
+ if (item is null)
+ {
+ return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a user.[/]";
+ }
+
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var suc = WorkbenchColors.Success.ToMarkup();
+ var activeColor = item.IsActive ? suc : mut;
+
+ return string.Join(
+ "\n",
+ $"[{mut}]Id[/] {item.Id}",
+ $"[{mut}]Username[/] {item.Username}",
+ $"[{mut}]Email[/] {item.Email ?? "—"}",
+ $"[{mut}]Active[/] [{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]",
+ $"[{mut}]Has Logged In[/] {item.HasLoggedIn}",
+ $"[{mut}]Created[/] {item.CreatedAt}");
+ }
+
+ ///
+ protected override bool MatchesFilter(User item, string filter) =>
+ item.Username.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ (item.Email ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs
new file mode 100644
index 0000000..f0c7610
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Builds a modal overlay window for displaying item detail in tabbed panes with optional action buttons.
+///
+public class DetailOverlayWindow
+{
+ Window? _window;
+
+ ///
+ /// Builds a detail overlay window with the specified title, tabbed content, and action buttons.
+ ///
+ /// The SharpConsoleUI window system used to close the window on Escape.
+ /// The overlay window title.
+ ///
+ /// A list of (TabName, Content) tuples. Each tab displays its markup content in a scrollable panel.
+ ///
+ ///
+ /// A list of (Label, Execute) tuples. Each entry produces a toolbar button that invokes the callback.
+ ///
+ /// A configured ready to be passed to windowSystem.AddWindow.
+ public Window Build(
+ ConsoleWindowSystem windowSystem,
+ string title,
+ IReadOnlyList<(string TabName, string Content)> tabs,
+ IReadOnlyList<(string Label, Action Execute)> actions)
+ {
+ var tabBuilder = Controls.TabControl();
+
+ foreach (var (tabName, content) in tabs)
+ {
+ // Use a read-only MultilineEdit so text can be selected with mouse/keyboard and copied.
+ // Markup is stripped to plain text — colors are not rendered in editable controls.
+ var plainText = Markup.Remove(content);
+ var editor = Controls.MultilineEdit(plainText)
+ .AsReadOnly(true)
+ .WrapWords()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .WithSelectionColors(WorkbenchColors.Accent, WorkbenchColors.Background)
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithFocusedColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .Build();
+
+ tabBuilder.AddTab(tabName, editor);
+ }
+
+ var tabControl = tabBuilder.Fill().Build();
+
+ var toolbarBuilder = Controls.Toolbar()
+ .WithBelowLine(false)
+ .WithAboveLine(true)
+ .WithAboveLineColor(WorkbenchColors.Muted);
+
+ foreach (var (label, execute) in actions)
+ {
+ toolbarBuilder.AddButton(label, (_, _) => execute());
+ }
+
+ var toolbar = toolbarBuilder.Build();
+
+ _window = new WindowBuilder(windowSystem)
+ .WithTitle(title)
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .Centered()
+ .WithSize(120, 35)
+ .AddControl(tabControl)
+ .AddControl(toolbar)
+ .OnKeyPressed(HandleKeyPress)
+ .Build();
+
+ return _window;
+
+ void HandleKeyPress(object? sender, KeyPressedEventArgs e)
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape && _window is not null)
+ {
+ windowSystem.CloseWindow(_window, activateParent: true, force: false);
+ e.Handled = true;
+ }
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs
new file mode 100644
index 0000000..e6da66b
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs
@@ -0,0 +1,372 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Chronicle.Contracts.Jobs;
+using Cratis.Chronicle.Contracts.Observation;
+using Cratis.Chronicle.Contracts.Recommendations;
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Helpers;
+using SharpConsoleUI.Rendering;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Composition root for the Chronicle Workbench TUI — creates and wires all subsystems, then builds the main window.
+///
+/// The SharpConsoleUI window system.
+/// The Chronicle data service.
+/// The workbench settings.
+/// The Chronicle gRPC service clients.
+/// Pre-fetched snapshot used to populate all views before the first frame is rendered.
+/// Persisted workbench state from the previous session (last nav index, interval).
+public class MainWindow(
+ ConsoleWindowSystem windowSystem,
+ WorkbenchDataService dataService,
+ WorkbenchSettings settings,
+ IServices services,
+ WorkbenchData initialData,
+ WorkbenchState state)
+{
+ readonly IWorkbenchView[] _views = WorkbenchViewRegistry.CreateViews();
+
+ string? _activeEventStore;
+ string? _activeNamespace;
+ Window? _window;
+
+ WorkbenchActionHandler? _actionHandler;
+ WorkbenchNavigation? _navigation;
+ WorkbenchRefreshLoop? _refreshLoop;
+ WorkbenchOverlays? _overlays;
+
+ ///
+ /// Builds the main window, composes all workbench subsystems, and returns the ready-to-show window.
+ ///
+ /// The fully configured .
+ public Window Build()
+ {
+ _actionHandler = new WorkbenchActionHandler(
+ windowSystem,
+ text =>
+ {
+ if (string.IsNullOrEmpty(text))
+ _refreshLoop?.UpdateTopPanel();
+ else
+ windowSystem.PanelStateService.TopStatus = text;
+ });
+
+ _navigation = new WorkbenchNavigation(
+ windowSystem,
+ _views,
+ settings,
+ () => _activeEventStore,
+ () => _activeNamespace,
+ storeName =>
+ {
+ _activeEventStore = storeName;
+ _activeNamespace = null;
+ _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview);
+ _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None));
+ },
+ nsName =>
+ {
+ _activeNamespace = nsName;
+ _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview);
+ _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None));
+ },
+ () => _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)),
+ () => _refreshLoop?.CurrentData);
+
+ _refreshLoop = new WorkbenchRefreshLoop(
+ dataService,
+ settings,
+ _views,
+ _navigation,
+ windowSystem,
+ () => _activeEventStore,
+ () => _activeNamespace);
+
+ WireViewCallbacks();
+
+ _overlays = new WorkbenchOverlays(windowSystem, _views, _navigation, _actionHandler, _refreshLoop);
+ var overlays = _overlays;
+ var keyDispatcher = new WorkbenchKeyDispatcher(
+ _navigation, _views, _actionHandler, windowSystem, overlays, settings, state, () => _window);
+ var menuBar = new WorkbenchMenuBar(_navigation, overlays, windowSystem, settings, state).Build();
+ var navView = _navigation.BuildNavigationView();
+
+ _refreshLoop.Initialize(initialData);
+
+ var builtWindow = new WindowBuilder(windowSystem)
+ .WithTitle(string.Empty)
+ .Maximized()
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithBackgroundGradient(
+ ColorGradient.FromColors([
+ WorkbenchColors.Background,
+ WorkbenchColors.Surface,
+ WorkbenchColors.Background
+ ]),
+ GradientDirection.DiagonalDown)
+ .Borderless() // cspell:ignore Borderless
+ .HideTitle()
+ .HideCloseButton()
+ .AddControl(menuBar)
+ .AddControl(navView)
+ .OnKeyPressed((_, e) => keyDispatcher.Dispatch(e))
+ .WithAsyncWindowThread(_refreshLoop.RunAsync)
+ .Build();
+
+ _window = builtWindow;
+
+ // The menu bar is the first added control so the window system gives it initial focus.
+ // Move focus to the nav view so arrow keys, action keys, and shortcuts work immediately.
+ _window.FocusControl(navView);
+
+ if (state.LastNavIndex > 0 && state.LastNavIndex < _views.Length)
+ {
+ _navigation.NavigateTo(state.LastNavIndex);
+ }
+
+ return builtWindow;
+ }
+
+ static string TruncateId(string s) => s.Length <= 40 ? s : s[..37] + "…";
+
+ void OpenObserversForEventTypeOverlay(string eventTypeId)
+ {
+ var snapshot = _refreshLoop?.CurrentData;
+ if (snapshot is null || _overlays is null)
+ {
+ return;
+ }
+
+ var matching = snapshot.Observers
+ .Where(o => (o.EventTypes ?? []).Any(et =>
+ string.Equals(et.Id, eventTypeId, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ _overlays.OpenObserversForEventType(
+ eventTypeId,
+ matching,
+ obs =>
+ {
+ _navigation!.NavigateTo(WorkbenchNavigation.IndexObservers);
+ if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov)
+ {
+ ov.SetFilter(obs.Id);
+ }
+ });
+ }
+
+ void WireViewCallbacks()
+ {
+ if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov)
+ {
+ ov.OnReplay = obs => _actionHandler!.ExecuteAction(
+ $"Replay observer '{TruncateId(obs.Id)}'",
+ () => services.Observers.Replay(new Replay
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ ObserverId = obs.Id,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }));
+
+ ov.OnReplayAll = observers => _actionHandler!.ConfirmThenExecuteAll(
+ $"Replay {observers.Count} observer{(observers.Count == 1 ? string.Empty : "s")}",
+ observers,
+ obs => services.Observers.Replay(new Replay
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ ObserverId = obs.Id,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }));
+ }
+
+ if (_views[WorkbenchNavigation.IndexFailures] is FailedPartitionsView fv)
+ {
+ fv.OnRetryPartition = fp => _actionHandler!.ExecuteAction(
+ $"Retry partition '{fp.Partition}'",
+ () => services.Observers.RetryPartition(new RetryPartition
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ ObserverId = fp.ObserverId,
+ Partition = fp.Partition,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }));
+
+ fv.OnReplayPartition = fp => _actionHandler!.ExecuteAction(
+ $"Replay partition '{fp.Partition}'",
+ () => services.Observers.ReplayPartition(new ReplayPartition
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ ObserverId = fp.ObserverId,
+ Partition = fp.Partition,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }));
+
+ fv.OnRetryAll = partitions => _actionHandler!.ConfirmThenExecuteAll(
+ $"Retry {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}",
+ partitions,
+ fp => services.Observers.RetryPartition(new RetryPartition
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ ObserverId = fp.ObserverId,
+ Partition = fp.Partition,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }));
+
+ fv.OnReplayAll = partitions => _actionHandler!.ConfirmThenExecuteAll(
+ $"Replay {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}",
+ partitions,
+ fp => services.Observers.ReplayPartition(new ReplayPartition
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ ObserverId = fp.ObserverId,
+ Partition = fp.Partition,
+ EventSequenceId = CliDefaults.DefaultEventSequenceId
+ }));
+ }
+
+ if (_views[WorkbenchNavigation.IndexJobs] is JobsView jv)
+ {
+ jv.OnStopJob = job => _actionHandler!.ExecuteAction(
+ $"Stop job '{TruncateId(job.Type ?? job.Id.ToString())}'",
+ () => services.Jobs.Stop(new StopJob
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ JobId = job.Id
+ }));
+
+ jv.OnResumeJob = job => _actionHandler!.ExecuteAction(
+ $"Resume job '{TruncateId(job.Type ?? job.Id.ToString())}'",
+ () => services.Jobs.Resume(new ResumeJob
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ JobId = job.Id
+ }));
+
+ jv.OnStopAll = jobs => _actionHandler!.ConfirmThenExecuteAll(
+ $"Stop {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}",
+ jobs,
+ job => services.Jobs.Stop(new StopJob
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ JobId = job.Id
+ }));
+
+ jv.OnResumeAll = jobs => _actionHandler!.ConfirmThenExecuteAll(
+ $"Resume {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}",
+ jobs,
+ job => services.Jobs.Resume(new ResumeJob
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ JobId = job.Id
+ }));
+ }
+
+ if (_views[WorkbenchNavigation.IndexRecommendations] is RecommendationsView rv)
+ {
+ rv.OnApply = rec => _actionHandler!.ExecuteAction(
+ $"Apply recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'",
+ () => services.Recommendations.Perform(new Perform
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ RecommendationId = rec.Id
+ }));
+
+ rv.OnIgnore = rec => _actionHandler!.ExecuteAction(
+ $"Ignore recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'",
+ () => services.Recommendations.Ignore(new Perform
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ RecommendationId = rec.Id
+ }));
+
+ rv.OnApplyAll = recs => _actionHandler!.ConfirmThenExecuteAll(
+ $"Apply {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}",
+ recs,
+ rec => services.Recommendations.Perform(new Perform
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ RecommendationId = rec.Id
+ }));
+
+ rv.OnIgnoreAll = recs => _actionHandler!.ConfirmThenExecuteAll(
+ $"Ignore {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}",
+ recs,
+ rec => services.Recommendations.Ignore(new Perform
+ {
+ EventStore = _activeEventStore ?? settings.ResolveEventStore(),
+ Namespace = _activeNamespace ?? settings.ResolveNamespace(),
+ RecommendationId = rec.Id
+ }));
+ }
+
+ if (_views[WorkbenchNavigation.IndexReadModels] is ReadModelsView rmv)
+ {
+ rmv.OnFetchInstances = async (containerName, ct) =>
+ await dataService.FetchAsync(
+ _activeEventStore,
+ _activeNamespace,
+ readModelContainerName: containerName,
+ ct);
+ }
+
+ if (_views[WorkbenchNavigation.IndexEventStores] is EventStoresView esv)
+ {
+ esv.OnSwitch = storeName =>
+ {
+ _activeEventStore = storeName;
+ _activeNamespace = null;
+ _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview);
+ _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None));
+ };
+ }
+
+ if (_views[WorkbenchNavigation.IndexNamespaces] is NamespacesView nsv)
+ {
+ nsv.OnSwitch = nsName =>
+ {
+ _activeNamespace = nsName;
+ _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview);
+ _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None));
+ };
+ }
+
+ if (_views[WorkbenchNavigation.IndexEventSequences] is EventSequencesView seqView)
+ {
+ seqView.OnViewEventTypeDefinition = evt =>
+ _overlays?.OpenEventTypeDefinition(
+ evt.Context.EventType.Id,
+ _refreshLoop?.CurrentData);
+
+ seqView.OnViewObserversForType = evt =>
+ OpenObserversForEventTypeOverlay(evt.Context.EventType.Id);
+ }
+
+ if (_views[WorkbenchNavigation.IndexEventTypes] is EventTypesView etView)
+ {
+ etView.OnViewObservers = reg =>
+ OpenObserversForEventTypeOverlay(reg.Type.Id);
+ }
+
+ foreach (var view in _views)
+ {
+ view.OnFilterFocusChanged = focused => _actionHandler!.TextInputFocused = focused;
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs
new file mode 100644
index 0000000..e1543f1
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Describes a single user-triggerable action that a view exposes.
+/// The same record is consumed by the keyboard dispatcher, the right-click context menu,
+/// and the Actions menu bar item — defining the action once eliminates duplication across all three.
+///
+/// Display text shown in menus and the context menu.
+/// Short key hint shown alongside the label (e.g. "R"). Display only — not handled here.
+/// The that activates this action, or for menu-only actions.
+/// Required modifier keys. Use for no modifiers.
+/// The delegate invoked when the action is activated.
+public record ViewAction(
+ string Label,
+ string? KeyHint,
+ ConsoleKey? TriggerKey,
+ ConsoleModifiers TriggerModifiers,
+ Action Execute);
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs
new file mode 100644
index 0000000..2e11337
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Shows centered confirmation dialogs for destructive workbench actions and executes them on confirmation.
+///
+/// The SharpConsoleUI window system — used to open the confirmation modal.
+/// Callback invoked to display progress / success / error messages in the top panel.
+public class WorkbenchActionHandler(ConsoleWindowSystem windowSystem, Action updateStatus)
+{
+ ///
+ /// Gets or sets a value indicating whether a text input control currently has keyboard focus.
+ /// When , most shortcut keys are suppressed so the user can type freely.
+ ///
+ public bool TextInputFocused { get; set; }
+
+ ///
+ /// Opens a centered confirmation dialog for .
+ /// Executes when the user confirms; dismisses on cancel.
+ ///
+ /// Short human-readable description of the action.
+ /// Async delegate that performs the action when confirmed.
+ public void ExecuteAction(string description, Func action) =>
+ ShowConfirmationDialog(description, action);
+
+ ///
+ /// Opens a confirmation dialog for a bulk operation over .
+ /// Calls for each item in sequence when confirmed.
+ ///
+ /// The element type.
+ /// Short human-readable description of the bulk action.
+ /// The collection of items to act on.
+ /// Async delegate called once per item.
+ public void ConfirmThenExecuteAll(string description, IReadOnlyList items, Func perItem)
+ {
+ ExecuteAction(description, async () =>
+ {
+ foreach (var item in items)
+ {
+ await perItem(item);
+ }
+ });
+ }
+
+ void ShowConfirmationDialog(string description, Func action)
+ {
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var warn = WorkbenchColors.Warning.ToMarkup();
+ var acc = WorkbenchColors.Accent.ToMarkup();
+
+ var body = Controls.Markup()
+ .AddEmptyLine()
+ .AddLine($" [{warn}]⚡[/] {description}")
+ .AddEmptyLine()
+ .AddLine($" [{mut}]This action cannot be undone.[/]")
+ .AddEmptyLine()
+ .AddLine($" [{mut}]Press[/] [bold {acc}]Enter[/] [{mut}]or[/] [bold {acc}]Y[/] [{mut}]to confirm, or[/] [bold {acc}]Escape[/] [{mut}]/ [bold {acc}]N[/] [{mut}]to cancel.[/]")
+ .AddEmptyLine()
+ .Build();
+
+ Window? dialog = null;
+ dialog = new WindowBuilder(windowSystem)
+ .WithTitle(" Confirm Action ")
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Surface)
+ .WithSize(64, 10)
+ .Centered()
+ .AddControl(body)
+ .OnKeyPressed((_, e) =>
+ {
+ switch (e.KeyInfo.Key)
+ {
+ case ConsoleKey.Enter:
+ case ConsoleKey.Y:
+ windowSystem.CloseWindow(dialog, activateParent: true, force: false);
+ RunAction(description, action);
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.Escape:
+ case ConsoleKey.N:
+ windowSystem.CloseWindow(dialog, activateParent: true, force: false);
+ e.Handled = true;
+ break;
+ }
+ })
+ .Build();
+
+ windowSystem.AddWindow(dialog, activateWindow: true);
+ }
+
+ void RunAction(string description, Func action)
+ {
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ updateStatus($"⟳ {description}…");
+ await action();
+ updateStatus("✓ Done");
+ await Task.Delay(3000);
+ updateStatus(string.Empty);
+ }
+ catch (Exception ex)
+ {
+ var msg = ex.Message.Length > 80 ? ex.Message[..80] : ex.Message;
+ updateStatus($"✗ {msg}");
+ }
+ });
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs
new file mode 100644
index 0000000..ba7e0dd
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs
@@ -0,0 +1,318 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Themes;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Translates raw key-press events from the main window into workbench actions.
+///
+/// Wired to Window.KeyPressed (fires after the focused control processes each key).
+/// All shortcuts live here because MenuControl only handles arrow keys, Enter, Escape,
+/// Home and End — it does NOT intercept letters or Ctrl+letter combinations, so those always
+/// bubble to this handler regardless of whether the menu bar currently has focus.
+///
+///
+/// Left and Right arrows use an guard so the
+/// NavigationView nav pane (collapse/expand headers) and menu bar (navigate items) can handle
+/// them without our focus-switch logic interfering.
+///
+///
+/// Navigation — used for view jumping, sidebar mode, and picker overlays.
+/// All view instances — used for jumping, filter, and view-action dispatch.
+/// Action handler — owns the text-input focus state flag.
+/// The window system — used for theme switching.
+/// Overlays — invoked for help, command palette, read model detail, and clipboard copy.
+/// Workbench settings — used by the Quit action to persist the refresh interval.
+/// Workbench state — used by the Quit action to persist the last active navigation index.
+/// Returns the main , used for focus control targeting.
+public class WorkbenchKeyDispatcher(
+ WorkbenchNavigation navigation,
+ IWorkbenchView[] views,
+ WorkbenchActionHandler actionHandler,
+ ConsoleWindowSystem windowSystem,
+ WorkbenchOverlays overlays,
+ WorkbenchSettings settings,
+ WorkbenchState state,
+ Func getWindow)
+{
+ bool _sidebarExpanded = true;
+
+ ///
+ /// Dispatches a key press to the appropriate workbench action.
+ /// Wired to Window.KeyPressed which fires after the focused control has processed the key.
+ ///
+ /// The key-press event.
+ public void Dispatch(KeyPressedEventArgs e)
+ {
+ if (navigation.NavView is null)
+ {
+ return;
+ }
+
+ // Suppress all shortcuts while the filter prompt is active so the user can type freely.
+ // Escape is the only exception — it exits filter mode.
+ if (actionHandler.TextInputFocused)
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ var filterIdx = navigation.CurrentViewIndex;
+ if (filterIdx >= 0 && filterIdx < views.Length)
+ {
+ views[filterIdx].ClearFilter();
+ }
+
+ e.Handled = true;
+ }
+
+ return;
+ }
+
+ var idx = navigation.CurrentViewIndex;
+
+ switch (e.KeyInfo.Key)
+ {
+ // Left/Right: only act when the focused control did NOT already handle them.
+ // The NavigationView nav pane handles Left (collapse header) and the menu bar handles
+ // Left/Right (navigate items). We skip FocusNavigation/FocusContent in those cases.
+ case ConsoleKey.LeftArrow:
+ if (!e.AlreadyHandled)
+ {
+ FocusNavigation();
+ e.Handled = true;
+ }
+
+ break;
+
+ case ConsoleKey.RightArrow:
+ if (!e.AlreadyHandled)
+ {
+ FocusContent();
+ e.Handled = true;
+ }
+
+ break;
+
+ case ConsoleKey.B when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control):
+ ToggleSidebar();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.Oem5 when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control):
+ ToggleDetailPane();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.E when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control):
+ navigation.OpenEventStorePicker();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.N when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control):
+ navigation.OpenNamespacePicker();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.C when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control):
+ overlays.CopyDetailToClipboard();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.Enter when idx == WorkbenchNavigation.IndexReadModels:
+ overlays.OpenReadModelDetail();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.Oem2 when e.KeyInfo.Modifiers == ConsoleModifiers.Shift:
+ if (!e.AlreadyHandled)
+ {
+ overlays.OpenHelpOverlay();
+ e.Handled = true;
+ }
+
+ break;
+
+ case ConsoleKey.P when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control):
+ overlays.OpenCommandPalette();
+ e.Handled = true;
+ break;
+
+ // Theme switching
+ case ConsoleKey.F9:
+ ApplyTheme(new ModernGrayTheme());
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.F10:
+ ApplyTheme(new ClassicTheme());
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.F11:
+ ApplyThemeByName("SharpConsoleUI.Plugins.DeveloperTools.DevDarkTheme, SharpConsoleUI");
+ e.Handled = true;
+ break;
+
+ // Page navigation
+ case ConsoleKey.Oem6: // ]
+ if (idx >= 0 && idx < views.Length) views[idx].NextPage();
+ e.Handled = true;
+ break;
+
+ case ConsoleKey.Oem4: // [
+ if (idx >= 0 && idx < views.Length) views[idx].PreviousPage();
+ e.Handled = true;
+ break;
+
+ // Row jumping — guarded so Shift+G and Home in the filter prompt aren't intercepted.
+ case ConsoleKey.G when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Shift):
+ if (!e.AlreadyHandled)
+ {
+ if (idx >= 0 && idx < views.Length) views[idx].JumpToLastRow();
+ e.Handled = true;
+ }
+
+ break;
+
+ case ConsoleKey.Home:
+ if (!e.AlreadyHandled)
+ {
+ if (idx >= 0 && idx < views.Length) views[idx].JumpToFirstRow();
+ e.Handled = true;
+ }
+
+ break;
+
+ // Single-character shortcuts: only act when the focused control did NOT already consume
+ // the key. When the filter prompt is active and the user types 'f', 'q', 'v', etc.,
+ // the prompt's ProcessKey returns true (alreadyHandled = true) and we skip these cases,
+ // so the character is inserted into the filter instead of triggering a shortcut.
+ case ConsoleKey.F:
+ if (!e.AlreadyHandled)
+ {
+ ActivateCurrentFilter();
+ e.Handled = true;
+ }
+
+ break;
+
+ case ConsoleKey.Q:
+ if (!e.AlreadyHandled)
+ {
+ state.Interval = settings.Interval;
+ state.LastNavIndex = navigation.CurrentViewIndex;
+ state.Save();
+ Environment.Exit(0);
+ }
+
+ break;
+
+ default:
+ // OCP: view-scoped action keys (R, V, D, T, P, S, U, A, I, etc.)
+ // Also guarded by AlreadyHandled — when the filter prompt is active,
+ // it consumes every printable key, so these action shortcuts are naturally
+ // suppressed without needing any separate TextInputFocused flag.
+ if (!e.AlreadyHandled && DispatchCurrentViewAction(e.KeyInfo.Key, e.KeyInfo.Modifiers))
+ {
+ e.Handled = true;
+ }
+
+ break;
+ }
+ }
+
+ void FocusNavigation()
+ {
+ var window = getWindow();
+ if (window is null || navigation.NavView is null)
+ {
+ return;
+ }
+
+ window.FocusControl(navigation.NavView);
+ }
+
+ void FocusContent()
+ {
+ var window = getWindow();
+ if (window is null)
+ {
+ return;
+ }
+
+ var idx = navigation.CurrentViewIndex;
+ if (idx >= 0 && idx < views.Length && views[idx].PrimaryFocusTarget is IInteractiveControl ic)
+ {
+ window.FocusControl(ic);
+ }
+ }
+
+ void ToggleSidebar()
+ {
+ if (navigation.NavView is null)
+ {
+ return;
+ }
+
+ _sidebarExpanded = !_sidebarExpanded;
+ navigation.NavView.PaneDisplayMode = _sidebarExpanded
+ ? NavigationViewDisplayMode.Expanded
+ : NavigationViewDisplayMode.Compact;
+ }
+
+ void ToggleDetailPane()
+ {
+ var idx = navigation.CurrentViewIndex;
+ if (idx >= 0 && idx < views.Length)
+ {
+ views[idx].ToggleDetailPane();
+ }
+ }
+
+ void ActivateCurrentFilter()
+ {
+ var idx = navigation.CurrentViewIndex;
+ var window = getWindow();
+ if (idx < 0 || idx >= views.Length || window is null)
+ {
+ return;
+ }
+
+ views[idx].ActivateFilter(window);
+ }
+
+ bool DispatchCurrentViewAction(ConsoleKey key, ConsoleModifiers modifiers)
+ {
+ var idx = navigation.CurrentViewIndex;
+ if (idx < 0 || idx >= views.Length)
+ {
+ return false;
+ }
+
+ var match = views[idx].ViewActions.FirstOrDefault(
+ a => a.TriggerKey == key && a.TriggerModifiers == modifiers);
+
+ if (match is null)
+ {
+ return false;
+ }
+
+ match.Execute();
+ return true;
+ }
+
+ void ApplyTheme(ITheme theme) =>
+ windowSystem.ThemeStateService.SetTheme(theme);
+
+ void ApplyThemeByName(string typeName)
+ {
+ var type = Type.GetType(typeName);
+ if (type is not null && Activator.CreateInstance(type) is ITheme theme)
+ {
+ ApplyTheme(theme);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs
new file mode 100644
index 0000000..c42c9c0
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Themes;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Builds the horizontal sticky menu bar displayed at the top of the workbench window.
+///
+/// Navigation — wired to Ctrl+E / Ctrl+N quick-switch actions.
+/// Overlays — wired to Help menu items.
+/// The window system — used for theme switching.
+/// Workbench settings — used by the Quit action to persist the refresh interval.
+/// Workbench state — used by the Quit action to persist the last active navigation index.
+public class WorkbenchMenuBar(
+ WorkbenchNavigation navigation,
+ WorkbenchOverlays overlays,
+ ConsoleWindowSystem windowSystem,
+ WorkbenchSettings settings,
+ WorkbenchState state)
+{
+ ///
+ /// Builds and returns the configured horizontal sticky .
+ ///
+ /// The fully wired menu bar control.
+ public MenuControl Build()
+ {
+ var menu = Controls.Menu()
+ .Horizontal()
+ .Sticky()
+ .WithName("WorkbenchMenuBar")
+ .AddItem("File", m => m
+ .AddItem("Switch Event Store", "Ctrl+E", () => navigation.OpenEventStorePicker())
+ .AddItem("Switch Namespace", "Ctrl+N", () => navigation.OpenNamespacePicker())
+ .AddSeparator()
+ .AddItem("Quit", "Q", () =>
+ {
+ state.Interval = settings.Interval;
+ state.LastNavIndex = navigation.CurrentViewIndex;
+ state.Save();
+ Environment.Exit(0);
+ }))
+ .AddItem("Help", m => m
+ .AddItem("Keyboard Shortcuts", "?", () => overlays.OpenHelpOverlay())
+ .AddSeparator()
+ .AddItem("Theme: Modern Gray", "F9", () => ApplyTheme(new ModernGrayTheme()))
+ .AddItem("Theme: Classic", "F10", () => ApplyTheme(new ClassicTheme()))
+ .AddItem("Theme: Dev Dark", "F11", () => ApplyThemeByName("SharpConsoleUI.Plugins.DeveloperTools.DevDarkTheme, SharpConsoleUI")))
+ .Build();
+
+ menu.StickyPosition = StickyPosition.Top;
+ return menu;
+ }
+
+ void ApplyTheme(ITheme theme) =>
+ windowSystem.ThemeStateService.SetTheme(theme);
+
+ void ApplyThemeByName(string typeName)
+ {
+ var type = Type.GetType(typeName);
+ if (type is not null && Activator.CreateInstance(type) is ITheme theme)
+ {
+ ApplyTheme(theme);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs
new file mode 100644
index 0000000..d894be0
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs
@@ -0,0 +1,412 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using UITableRow = SharpConsoleUI.Controls.TableRow;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Builds and manages the navigation side pane, badge counts, and the event store / namespace picker overlays.
+/// All navigation items are driven by — add a view there and it appears here automatically.
+///
+/// The SharpConsoleUI window system.
+/// The ordered array of instances — must match order.
+/// Workbench settings — used to resolve the active event store and namespace.
+/// Returns the currently active event store name, or for the default.
+/// Returns the currently active namespace name, or for the default.
+/// Invoked with the newly selected event store name when the user picks one from the picker overlay.
+/// Invoked with the newly selected namespace name when the user picks one from the picker overlay.
+/// Invoked when navigation changes require a data refresh.
+/// Returns the latest cached snapshot, or if none available yet.
+public class WorkbenchNavigation(
+ ConsoleWindowSystem windowSystem,
+ IWorkbenchView[] views,
+ WorkbenchSettings settings,
+ Func getActiveEventStore,
+ Func getActiveNamespace,
+ Action onStoreSwitch,
+ Action onNamespaceSwitch,
+ Action onDataNeeded,
+ Func getLatestData)
+{
+ // ── View index constants ───────────────────────────────────────────────────
+ // Derived from WorkbenchViewRegistry — the registry position IS the index.
+ // Never hardcode these values; never pass them to NavigationView.SelectedIndex directly.
+ // Always navigate via NavigateTo(IndexXxx) which converts to the header-inclusive index.
+
+ /// View index for Overview.
+ public static readonly int IndexOverview = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Observers.
+ public static readonly int IndexObservers = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Failures.
+ public static readonly int IndexFailures = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Jobs.
+ public static readonly int IndexJobs = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Recommendations.
+ public static readonly int IndexRecommendations = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Event Sequences.
+ public static readonly int IndexEventSequences = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Event Types.
+ public static readonly int IndexEventTypes = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Projections.
+ public static readonly int IndexProjections = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Read Models.
+ public static readonly int IndexReadModels = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Event Stores.
+ public static readonly int IndexEventStores = WorkbenchViewRegistry.IndexOf();
+
+ /// View index for Namespaces.
+ public static readonly int IndexNamespaces = WorkbenchViewRegistry.IndexOf();
+
+ const int PickerOverlayWidth = 54;
+ const int MaxPickerOverlayHeight = 24;
+ const int PickerOverlayHeightPadding = 6;
+ const int NavExpandedThreshold = 90;
+ const int NavCompactThreshold = 40;
+
+ NavigationItem? _observersItem;
+ NavigationItem? _failuresItem;
+ NavigationItem? _recommendationsItem;
+ NavigationView? _navView;
+ int _currentViewIndex;
+
+ /// Gets the built control. Only available after has been called.
+ public NavigationView? NavView => _navView;
+
+ ///
+ /// Gets the zero-based item-only index of the currently active view.
+ /// Excludes header entries, aligns with IndexXxx constants, and can be used directly to index into views[].
+ ///
+ public int CurrentViewIndex => _currentViewIndex;
+
+ /// Gets the Observers navigation item (used to set badge counts). Only available after .
+ public NavigationItem? ObserversItem => _observersItem;
+
+ /// Gets the Failures navigation item (used to set badge counts). Only available after .
+ public NavigationItem? FailuresItem => _failuresItem;
+
+ /// Gets the Recommendations navigation item (used to set badge counts). Only available after .
+ public NavigationItem? RecommendationsItem => _recommendationsItem;
+
+ ///
+ /// Builds the navigation view from — headers and items are derived automatically.
+ /// Wires the selection-changed callback and captures the badge item references.
+ ///
+ /// The fully configured .
+ public NavigationView BuildNavigationView()
+ {
+ // Declare navView before the builder chain so the lambda can capture it.
+ // It will be null when OnSelectedItemChanged fires during the initial build-time
+ // auto-selection; the guard below handles that case gracefully.
+ NavigationView? navView = null;
+
+ navView = Controls.NavigationView()
+ .WithNavWidth(28)
+ .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] ◆ CHRONICLE[/]")
+ .WithSelectedColors(new SharpConsoleUI.Color(255, 255, 255, 255), WorkbenchColors.SelectedBg)
+ .WithContentBorder(BorderStyle.Rounded)
+ .WithContentBorderColor(WorkbenchColors.ContentBorder)
+ .WithContentBackground(WorkbenchColors.Surface)
+ .WithContentPadding(1, 0, 1, 0)
+ .WithPaneDisplayMode(NavigationViewDisplayMode.Auto)
+ .WithExpandedThreshold(NavExpandedThreshold)
+ .WithCompactThreshold(NavCompactThreshold)
+ .WithName("MainNav")
+ .Fill()
+ .OnSelectedItemChanged((_, e) =>
+ {
+ // navView may be null while Build() is still executing (first auto-selection).
+ // In that case _currentViewIndex stays at its default of 0 (Overview), which is correct.
+ if (navView is null)
+ {
+ return;
+ }
+
+ var oldViewIdx = ToViewIndex(navView, e.OldIndex);
+ if (oldViewIdx >= 0 && oldViewIdx < views.Length)
+ {
+ views[oldViewIdx].IsActive = false;
+ }
+
+ var idx = ToViewIndex(navView, e.NewIndex);
+ _currentViewIndex = idx >= 0 ? idx : 0;
+
+ if (idx < 0 || idx >= views.Length)
+ {
+ return;
+ }
+
+ var snapshot = getLatestData();
+ if (snapshot is not null)
+ {
+ views[idx].UpdateData(snapshot);
+ }
+ else
+ {
+ onDataNeeded();
+ }
+
+ views[idx].IsActive = true;
+ })
+ .Build();
+
+ // Add headers and items driven entirely by the registry.
+ // Adding a new view to WorkbenchViewRegistry.All automatically adds it here.
+ WorkbenchSection? lastSection = null;
+ NavigationItem? currentHeader = null;
+
+ for (var i = 0; i < WorkbenchViewRegistry.All.Count; i++)
+ {
+ var def = WorkbenchViewRegistry.All[i];
+ var viewIndex = i;
+
+ if (!ReferenceEquals(def.Section, lastSection))
+ {
+ currentHeader = navView!.AddHeader(def.Section.Title, def.Section.Color);
+ lastSection = def.Section;
+ }
+
+ var navItem = navView!.AddItemToHeader(currentHeader!, def.NavText, def.NavIcon, def.NavSubtitle);
+ navView.SetItemContent(navItem, panel => panel.AddControl(views[viewIndex].BuildContent(windowSystem)));
+ }
+
+ var allItems = navView!.Items;
+ _observersItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexObservers].NavText);
+ _failuresItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexFailures].NavText);
+ _recommendationsItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexRecommendations].NavText);
+
+ _navView = navView;
+
+ // Guard: registry count must equal views.Length — they're both built from the same registry.
+ var nonHeaderCount = allItems.Count(i => i.ItemType != NavigationItemType.Header);
+ System.Diagnostics.Debug.Assert(
+ nonHeaderCount == views.Length,
+ $"Nav has {nonHeaderCount} selectable items but _views has {views.Length}. Both must match {nameof(WorkbenchViewRegistry)}.");
+
+ return navView;
+ }
+
+ /// Navigates to the specified view by index. No-op when the index is out of range.
+ /// Zero-based item-only view index (use IndexXxx constants).
+ public void NavigateTo(int viewIndex)
+ {
+ if (_navView is null || viewIndex < 0 || viewIndex >= views.Length)
+ {
+ return;
+ }
+
+ var navIndex = ToNavIndex(_navView, viewIndex);
+ if (navIndex >= 0)
+ {
+ _navView.SelectedIndex = navIndex;
+ }
+ }
+
+ /// Updates the badge subtitles on the Observers, Failures, and Recommendations nav items.
+ /// The latest workbench data snapshot.
+ public void UpdateNavBadges(WorkbenchData data)
+ {
+ var problemCount = data.DisconnectedObservers + data.ReplayingObservers;
+
+ if (_observersItem is NavigationItem observersItem)
+ {
+ observersItem.Subtitle = problemCount > 0 ? $"⚠{problemCount}" : string.Empty;
+ }
+
+ if (_failuresItem is NavigationItem failuresItem)
+ {
+ failuresItem.Subtitle = data.FailedPartitions.Count > 0
+ ? data.FailedPartitions.Count.ToString()
+ : string.Empty;
+ }
+
+ if (_recommendationsItem is NavigationItem recommendationsItem)
+ {
+ recommendationsItem.Subtitle = data.Recommendations.Count > 0
+ ? data.Recommendations.Count.ToString()
+ : string.Empty;
+ }
+
+ _navView?.Invalidate();
+ }
+
+ /// Opens a modal picker that lets the user select a different event store.
+ public void OpenEventStorePicker()
+ {
+ var snapshot = getLatestData();
+ if (snapshot is null)
+ {
+ return;
+ }
+
+ ShowStringPickerOverlay(
+ " Switch Event Store ",
+ "Event Store",
+ "EventStorePickerTable",
+ [.. snapshot.EventStoreNames.Order()],
+ getActiveEventStore() ?? settings.ResolveEventStore(),
+ onStoreSwitch);
+ }
+
+ /// Opens a modal picker that lets the user select a different namespace.
+ public void OpenNamespacePicker()
+ {
+ var snapshot = getLatestData();
+ if (snapshot is null)
+ {
+ return;
+ }
+
+ ShowStringPickerOverlay(
+ " Switch Namespace ",
+ "Namespace",
+ "NamespacePickerTable",
+ [.. snapshot.NamespaceNames.Order()],
+ getActiveNamespace() ?? settings.ResolveNamespace(),
+ onNamespaceSwitch);
+ }
+
+ ///
+ /// Converts a header-inclusive NavigationView item index to a zero-based view-only index.
+ /// Takes the nav view directly so it works inside lambdas before _navView is assigned.
+ /// Returns -1 for header entries or out-of-range indices.
+ ///
+ /// The NavigationView to query.
+ /// The header-inclusive index from the NavigationView.
+ /// The zero-based view-only index, or -1 if not applicable.
+ static int ToViewIndex(NavigationView nav, int navIndex)
+ {
+ if (navIndex < 0)
+ {
+ return -1;
+ }
+
+ var items = nav.Items;
+ if (navIndex >= items.Count || items[navIndex].ItemType == NavigationItemType.Header)
+ {
+ return -1;
+ }
+
+ var viewIdx = 0;
+ for (var i = 0; i < navIndex; i++)
+ {
+ if (items[i].ItemType != NavigationItemType.Header)
+ {
+ viewIdx++;
+ }
+ }
+
+ return viewIdx;
+ }
+
+ ///
+ /// Converts a zero-based view-only index to the header-inclusive NavigationView item index
+ /// required by .
+ /// Returns -1 when the view index is not found.
+ ///
+ /// The NavigationView to query.
+ /// The zero-based view-only index.
+ /// The header-inclusive NavigationView index, or -1 if not found.
+ static int ToNavIndex(NavigationView nav, int viewIndex)
+ {
+ if (viewIndex < 0)
+ {
+ return -1;
+ }
+
+ var items = nav.Items;
+ var itemCount = 0;
+ for (var navIdx = 0; navIdx < items.Count; navIdx++)
+ {
+ if (items[navIdx].ItemType != NavigationItemType.Header)
+ {
+ if (itemCount == viewIndex)
+ {
+ return navIdx;
+ }
+
+ itemCount++;
+ }
+ }
+
+ return -1;
+ }
+
+ static NavigationItem? FindItemByText(IReadOnlyList items, string text)
+ {
+ foreach (var item in items)
+ {
+ if (item.Text == text)
+ {
+ return item;
+ }
+ }
+
+ return null;
+ }
+
+ void ShowStringPickerOverlay(
+ string title,
+ string columnHeader,
+ string tableName,
+ List items,
+ string activeItem,
+ Action onSelected)
+ {
+ var acc = WorkbenchColors.Accent.ToMarkup();
+
+ var pickerTable = Controls.Table()
+ .AddColumn(columnHeader, SharpConsoleUI.Layout.TextJustification.Left, null)
+ .Interactive()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .WithName(tableName)
+ .Build();
+
+ foreach (var name in items)
+ {
+ var label = name == activeItem ? $"[{acc}]► {name}[/]" : name;
+ pickerTable.AddRow(new UITableRow([label]) { Tag = name });
+ }
+
+ Window? picker = null;
+ var height = Math.Min(items.Count + PickerOverlayHeightPadding, MaxPickerOverlayHeight);
+ picker = new WindowBuilder(windowSystem)
+ .WithTitle(title)
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithSize(PickerOverlayWidth, height)
+ .Centered()
+ .AddControl(pickerTable)
+ .OnKeyPressed((_, ke) =>
+ {
+ if (ke.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ windowSystem.CloseWindow(picker, activateParent: true, force: false);
+ ke.Handled = true;
+ }
+ })
+ .Build();
+
+ pickerTable.RowActivated += (_, _) =>
+ {
+ if (pickerTable.SelectedRow?.Tag is string selected)
+ {
+ windowSystem.CloseWindow(picker, activateParent: true, force: false);
+ onSelected(selected);
+ }
+ };
+
+ windowSystem.AddWindow(picker, activateWindow: true);
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs
new file mode 100644
index 0000000..3c58471
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs
@@ -0,0 +1,535 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Helpers;
+using UITableRow = SharpConsoleUI.Controls.TableRow;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Opens and manages modal popup windows: keyboard-shortcuts help, command palette, and read model detail.
+/// Also owns clipboard copy, which is triggered from both the keyboard and the menu bar.
+///
+/// The SharpConsoleUI window system.
+/// All view instances — used to read current-view help and selected items.
+/// Navigation — provides the current view index and navigate methods.
+/// Action handler — owns the TextInputFocused flag used by the command palette prompt.
+/// Refresh loop — provides the latest data snapshot and temporary panel messages.
+public class WorkbenchOverlays(
+ ConsoleWindowSystem windowSystem,
+ IWorkbenchView[] views,
+ WorkbenchNavigation navigation,
+ WorkbenchActionHandler actionHandler,
+ WorkbenchRefreshLoop refreshLoop)
+{
+ const int HelpOverlayWidth = 72;
+ const int HelpOverlayHeight = 40;
+ const int CommandPaletteWidth = 80;
+ const int CommandPaletteHeight = 18;
+ const int MaxCommandPaletteResults = 10;
+
+ ///
+ /// Opens the keyboard-shortcuts help overlay. Shows a view-specific section at the top when the active
+ /// view exposes text.
+ ///
+ public void OpenHelpOverlay()
+ {
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var acc = WorkbenchColors.Accent.ToMarkup();
+
+ var activeIdx = navigation.CurrentViewIndex;
+ var currentViewHelp = string.Empty;
+ if (activeIdx >= 0 && activeIdx < views.Length && !string.IsNullOrEmpty(views[activeIdx].ViewHelp))
+ {
+ currentViewHelp =
+ $"[bold {acc}]THIS VIEW[/]\n" +
+ string.Join('\n', views[activeIdx].ViewHelp.Split('\n').Select(l => $" {l}")) +
+ "\n\n";
+ }
+
+ var helpText =
+ currentViewHelp +
+ $"[bold {acc}]NAVIGATION[/]\n" +
+ $" [{mut}]↑ ↓[/] Move selection up / down\n" +
+ $" [{mut}]← / →[/] Focus sidebar / content pane\n" +
+ $" [{mut}]Home / Shift+G[/] Jump to first / last row\n" +
+ $" [{mut}]Ctrl+B[/] Toggle sidebar expand / compact\n" +
+ $" [{mut}]Ctrl+\\[/] Toggle detail pane\n" +
+ $" [{mut}]Enter[/] Open detail overlay\n" +
+ $" [{mut}]Esc[/] Close overlay\n" +
+ "\n" +
+ $"[bold {acc}]QUICK SWITCH[/]\n" +
+ $" [{mut}]Ctrl+E[/] Switch event store\n" +
+ $" [{mut}]Ctrl+N[/] Switch namespace\n" +
+ "\n" +
+ $"[bold {acc}]FILTER & SEARCH[/]\n" +
+ $" [{mut}]F[/] Focus filter prompt for current view\n" +
+ $" [{mut}]Ctrl+P[/] Open command palette\n" +
+ $" [{mut}]Escape[/] Clear filter and return focus to table\n" +
+ "\n" +
+ $"[bold {acc}]VIEW ACTIONS (when row selected)[/]\n" +
+ $" [{mut}]?[/] See this screen — actions vary per view\n" +
+ $" [{mut}]Y / N[/] Confirm / Cancel pending action\n" +
+ "\n" +
+ $"[bold {acc}]CLIPBOARD[/]\n" +
+ $" [{mut}]Ctrl+C[/] Copy detail pane content to clipboard\n" +
+ "\n" +
+ $"[bold {acc}]THEMES[/]\n" +
+ $" [{mut}]F9 / F10 / F11[/] Modern Gray / Classic / Dev Dark theme\n" +
+ "\n" +
+ $"[bold {acc}]GENERAL[/]\n" +
+ $" [{mut}]+ / -[/] Increase / decrease refresh interval\n" +
+ $" [{mut}]?[/] This help screen\n" +
+ $" [{mut}]Q[/] Quit";
+
+ var markup = new MarkupControl([helpText]) { Wrap = true };
+ var content = Controls.ScrollablePanel()
+ .AddControl(markup)
+ .WithVerticalScroll(ScrollMode.Scroll)
+ .WithPadding(2, 1, 2, 1)
+ .Build();
+
+ Window? helpWindow = null;
+ helpWindow = new WindowBuilder(windowSystem)
+ .WithTitle(" Keyboard Shortcuts ")
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithSize(HelpOverlayWidth, HelpOverlayHeight)
+ .Centered()
+ .AddControl(content)
+ .OnKeyPressed((_, e) =>
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape ||
+ (e.KeyInfo.Key == ConsoleKey.Oem2 && e.KeyInfo.Modifiers == ConsoleModifiers.Shift))
+ {
+ windowSystem.CloseWindow(helpWindow, activateParent: true, force: false);
+ }
+
+ e.Handled = true;
+ })
+ .Build();
+
+ windowSystem.AddWindow(helpWindow, activateWindow: true);
+ }
+
+ ///
+ /// Opens the command palette overlay for searching observers, event types, projections, read models, and failures.
+ ///
+ public void OpenCommandPalette()
+ {
+ var snapshot = refreshLoop.CurrentData;
+ if (snapshot is null)
+ {
+ return;
+ }
+
+ var acc = WorkbenchColors.Accent.ToMarkup();
+
+ var resultsTable = Controls.Table()
+ .AddColumn("Kind", SharpConsoleUI.Layout.TextJustification.Left, 20)
+ .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null)
+ .Interactive()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .WithName("CommandPaletteResults")
+ .Build();
+
+ var searchPrompt = Controls.Prompt($"[{acc}]>[/] ")
+ .WithName("CommandPaletteSearch")
+ .OnGotFocus((_, _) => actionHandler.TextInputFocused = true)
+ .OnLostFocus((_, _) => actionHandler.TextInputFocused = false)
+ .Build();
+
+ void PopulateResults(string query)
+ {
+ resultsTable.ClearRows();
+
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ return;
+ }
+
+ var matches = new List<(string Kind, string Label, Action Navigate)>();
+
+ foreach (var obs in snapshot.Observers)
+ {
+ if (obs.Id.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ var obsId = obs.Id;
+ matches.Add(("Observer", $"{obs.Id} [{obs.RunningState}]", () =>
+ {
+ navigation.NavigateTo(WorkbenchNavigation.IndexObservers);
+ NavigateAndFilter(WorkbenchNavigation.IndexObservers, obsId);
+ }));
+ }
+ }
+
+ foreach (var et in snapshot.EventTypeRegistrations)
+ {
+ if (et.Type.Id.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ var typeId = et.Type.Id;
+ matches.Add(("Event Type", $"{et.Type.Id} gen {et.Type.Generation}", () =>
+ {
+ navigation.NavigateTo(WorkbenchNavigation.IndexEventTypes);
+ NavigateAndFilter(WorkbenchNavigation.IndexEventTypes, typeId);
+ }));
+ }
+ }
+
+ foreach (var pd in snapshot.ProjectionDefinitions)
+ {
+ if (pd.Identifier.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ var id = pd.Identifier;
+ matches.Add(("Projection", pd.Identifier, () =>
+ {
+ navigation.NavigateTo(WorkbenchNavigation.IndexProjections);
+ NavigateAndFilter(WorkbenchNavigation.IndexProjections, id);
+ }));
+ }
+ }
+
+ foreach (var rm in snapshot.ReadModelDefinitions)
+ {
+ if (rm.ContainerName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ rm.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ var label = rm.DisplayName.Length > 0 ? rm.DisplayName : rm.ContainerName;
+ matches.Add(("Read Model", label, () =>
+ {
+ navigation.NavigateTo(WorkbenchNavigation.IndexReadModels);
+ NavigateAndFilter(WorkbenchNavigation.IndexReadModels, label);
+ }));
+ }
+ }
+
+ foreach (var fp in snapshot.FailedPartitions)
+ {
+ if (fp.ObserverId.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ fp.Partition.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ var obsId = fp.ObserverId;
+ matches.Add(("Failure", $"{fp.ObserverId}/{fp.Partition}", () =>
+ {
+ navigation.NavigateTo(WorkbenchNavigation.IndexFailures);
+ NavigateAndFilter(WorkbenchNavigation.IndexFailures, obsId);
+ }));
+ }
+ }
+
+ foreach (var (kind, label, navigate) in matches.Take(MaxCommandPaletteResults))
+ {
+ resultsTable.AddRow(new UITableRow([kind, label]) { Tag = navigate });
+ }
+ }
+
+ searchPrompt.InputChanged += (_, text) => PopulateResults(text ?? string.Empty);
+
+ Window? paletteWindow = null;
+ paletteWindow = new WindowBuilder(windowSystem)
+ .WithTitle(" Command Palette ")
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithSize(CommandPaletteWidth, CommandPaletteHeight)
+ .Centered()
+ .AddControl(searchPrompt)
+ .AddControl(resultsTable)
+ .OnKeyPressed((_, e) =>
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false);
+ e.Handled = true;
+ return;
+ }
+
+ if (e.KeyInfo.Key == ConsoleKey.Enter)
+ {
+ var selectedRow = resultsTable.SelectedRow;
+ if (selectedRow?.Tag is Action navigate)
+ {
+ windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false);
+ navigate();
+ }
+
+ e.Handled = true;
+ }
+ })
+ .Build();
+
+ windowSystem.AddWindow(paletteWindow, activateWindow: true);
+ }
+
+ ///
+ /// Opens the detail overlay for the currently selected read model entry.
+ /// No-op when the Read Models view is not active or nothing is selected.
+ ///
+ public void OpenReadModelDetail()
+ {
+ if (views[WorkbenchNavigation.IndexReadModels] is ReadModelsView rmv)
+ {
+ rmv.OpenSelectedDetailOverlay();
+ }
+ }
+
+ ///
+ /// Copies the detail pane content of the active view to the system clipboard.
+ /// Shows a brief confirmation message in the top panel.
+ /// No-op when no content is available.
+ ///
+ public void CopyDetailToClipboard()
+ {
+ var idx = navigation.CurrentViewIndex;
+ if (idx < 0 || idx >= views.Length)
+ {
+ return;
+ }
+
+ var content = views[idx].DetailContent;
+ if (string.IsNullOrEmpty(content))
+ {
+ return;
+ }
+
+ ClipboardHelper.SetText(Markup.Remove(content));
+ refreshLoop.ShowTemporaryMessage("✓ Copied to clipboard");
+ }
+
+ ///
+ /// Opens a modal overlay listing all that subscribe to
+ /// . The user can inspect each observer's detail, press Enter to
+ /// navigate directly to it in the Observers view, or Escape to close.
+ ///
+ /// The event type identifier — shown in the window title.
+ /// Pre-filtered list of observers subscribed to this event type.
+ /// Invoked with the selected observer when the user confirms navigation.
+ public void OpenObserversForEventType(
+ string eventTypeId,
+ IReadOnlyList matchingObservers,
+ Action navigateToObserver)
+ {
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var warn = WorkbenchColors.Warning.ToMarkup();
+
+ var table = Controls.Table()
+ .AddColumn("State", SharpConsoleUI.Layout.TextJustification.Left, 18)
+ .AddColumn("Observer", SharpConsoleUI.Layout.TextJustification.Left, null)
+ .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 14)
+ .Interactive()
+ .WithVerticalScrollbar(ScrollbarVisibility.Auto)
+ .WithName("ObserversForEventTypeTable")
+ .Build();
+
+ var detailPanel = Controls.Panel()
+ .WithContent($"[{mut}]Select an observer.[/]")
+ .WithHeader(" OBSERVER ")
+ .Rounded()
+ .WithBorderColor(WorkbenchColors.Warning)
+ .WithPadding(1, 0, 1, 0)
+ .FillVertical()
+ .WithName("ObserversForEventTypeDetail")
+ .Build();
+
+ foreach (var obs in matchingObservers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id))
+ {
+ var icon = ObserverIcon(obs);
+ var color = ObserverStateColor(obs);
+ table.AddRow(new UITableRow([$"[{color}]{icon} {obs.RunningState}[/]", obs.Id, obs.Type.ToString()]) { Tag = obs });
+ }
+
+ if (matchingObservers.Count == 0)
+ {
+ table.AddRow(new UITableRow([$"[{mut}]—[/]", $"[{mut}]No observers found for this event type[/]", string.Empty]));
+ }
+
+ table.SelectedRowChanged += (_, _) =>
+ {
+ if (table.SelectedRow?.Tag is ObserverInformation obs)
+ {
+ detailPanel.Content = RenderObserverDetail(obs, mut, acc, warn);
+ }
+ };
+
+ // Select first row to populate the detail panel immediately
+ if (table.Rows.Count > 0 && table.Rows[0].Tag is ObserverInformation first)
+ {
+ table.SelectedRowIndex = 0;
+ detailPanel.Content = RenderObserverDetail(first, mut, acc, warn);
+ }
+
+ var layout = HorizontalGridControl.Create()
+ .Column(c => c.Add(table))
+ .WithSplitterAfter(0)
+ .Column(c => c.Width(38).Add(detailPanel))
+ .Build();
+
+ var width = Math.Min(96, Console.WindowWidth - 4);
+ var height = Math.Min(28, Console.WindowHeight - 4);
+
+ Window? overlayWindow = null;
+ overlayWindow = new WindowBuilder(windowSystem)
+ .WithTitle($" Observers for {eventTypeId} ")
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithSize(width, height)
+ .Centered()
+ .AddControl(layout)
+ .OnKeyPressed((_, e) =>
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ windowSystem.CloseWindow(overlayWindow, activateParent: true, force: false);
+ e.Handled = true;
+ return;
+ }
+
+ if (e.KeyInfo.Key == ConsoleKey.Enter &&
+ table.SelectedRow?.Tag is ObserverInformation obs)
+ {
+ windowSystem.CloseWindow(overlayWindow, activateParent: true, force: false);
+ navigateToObserver(obs);
+ e.Handled = true;
+ }
+ })
+ .Build();
+
+ windowSystem.AddWindow(overlayWindow, activateWindow: true);
+ }
+
+ ///
+ /// Opens a modal overlay showing the full definition — name, generation, owner, and schema — for
+ /// the event type identified by .
+ ///
+ /// The event type identifier to look up.
+ /// The current data snapshot used to locate the registration.
+ public void OpenEventTypeDefinition(string eventTypeId, WorkbenchData? snapshot)
+ {
+ var mut = WorkbenchColors.Muted.ToMarkup();
+ var acc = WorkbenchColors.Accent.ToMarkup();
+ var teal = WorkbenchColors.Teal.ToMarkup();
+
+ EventTypeRegistration? reg = null;
+ if (snapshot is not null)
+ {
+ reg = snapshot.EventTypeRegistrations
+ .FirstOrDefault(r => string.Equals(r.Type.Id, eventTypeId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ string content;
+ if (reg is null)
+ {
+ content = $"[{mut}]No registration found for[/] {eventTypeId}";
+ }
+ else
+ {
+ var schemaContent = !string.IsNullOrEmpty(reg.Schema)
+ ? JsonYamlFormatter.FormatAsYaml(reg.Schema, mut)
+ : $"[{mut}](no schema)[/]";
+
+ content = string.Join(
+ '\n',
+ [
+ $"[bold {teal}]{reg.Type.Id}[/] [{mut}]gen {reg.Type.Generation}[/]",
+ string.Empty,
+ $"[{mut}]Owner[/] {reg.Owner}",
+ $"[{mut}]Source[/] {reg.Source}",
+ $"[{mut}]Tombstone[/] {reg.Type.Tombstone}",
+ string.Empty,
+ $"[{acc}]Schema:[/]",
+ schemaContent
+ ]);
+ }
+
+ var markup = new MarkupControl([content]) { Wrap = true };
+ var scrollable = Controls.ScrollablePanel()
+ .AddControl(markup)
+ .WithVerticalScroll(ScrollMode.Scroll)
+ .WithPadding(2, 1, 2, 1)
+ .Build();
+
+ var width = Math.Min(80, Console.WindowWidth - 4);
+ var height = Math.Min(32, Console.WindowHeight - 4);
+
+ Window? defWindow = null;
+ defWindow = new WindowBuilder(windowSystem)
+ .WithTitle($" Event Type: {eventTypeId} ")
+ .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background)
+ .WithSize(width, height)
+ .Centered()
+ .AddControl(scrollable)
+ .OnKeyPressed((_, e) =>
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ windowSystem.CloseWindow(defWindow, activateParent: true, force: false);
+ e.Handled = true;
+ }
+ })
+ .Build();
+
+ windowSystem.AddWindow(defWindow, activateWindow: true);
+ }
+
+ static string RenderObserverDetail(ObserverInformation obs, string mut, string acc, string warn)
+ {
+ var color = ObserverStateColor(obs);
+ var lastSeq = obs.LastHandledEventSequenceNumber == ulong.MaxValue
+ ? "N/A"
+ : obs.LastHandledEventSequenceNumber.ToString("N0");
+
+ var lines = new List
+ {
+ $"[{mut}]Id[/] {obs.Id}",
+ $"[{mut}]Type[/] {obs.Type}",
+ $"[{mut}]State[/] [{color}]{obs.RunningState}[/]",
+ $"[{mut}]Next #[/] {obs.NextEventSequenceNumber:N0}",
+ $"[{mut}]Last #[/] {lastSeq}",
+ string.Empty,
+ $"[{acc}]Event Types:[/]"
+ };
+
+ foreach (var et in (obs.EventTypes ?? []).OrderBy(e => e.Id))
+ {
+ lines.Add($" [{mut}]•[/] {et.Id} [{mut}]gen {et.Generation}[/]");
+ }
+
+ lines.Add(string.Empty);
+ lines.Add($"[{mut}]Enter[/] → go to observer in main view");
+
+ return string.Join('\n', lines);
+ }
+
+ static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch
+ {
+ ObserverRunningState.Disconnected => 0,
+ ObserverRunningState.Replaying => 1,
+ ObserverRunningState.Active => 2,
+ ObserverRunningState.Suspended => 3,
+ _ => 4
+ };
+
+ static string ObserverStateColor(ObserverInformation obs) => obs.RunningState switch
+ {
+ ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(),
+ ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(),
+ ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(),
+ _ => WorkbenchColors.Muted.ToMarkup()
+ };
+
+ static string ObserverIcon(ObserverInformation obs) => obs.RunningState switch
+ {
+ ObserverRunningState.Active => "●",
+ ObserverRunningState.Replaying => "▲",
+ ObserverRunningState.Disconnected => "⊘",
+ _ => "○"
+ };
+
+ void NavigateAndFilter(int viewIndex, string filter)
+ {
+ if (viewIndex >= 0 && viewIndex < views.Length)
+ {
+ views[viewIndex].SetFilter(filter);
+ }
+ }
+}
diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs
new file mode 100644
index 0000000..fc44221
--- /dev/null
+++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs
@@ -0,0 +1,205 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using SharpConsoleUI;
+
+namespace Cratis.Cli.Commands.Chronicle.Workbench;
+
+///
+/// Manages the periodic data refresh loop, pushes snapshots to views, and keeps the top status panel current.
+///
+/// The Chronicle data service.
+/// Workbench settings — controls the refresh interval and connection string.
+/// All view instances — each receives every fresh snapshot.
+/// Navigation — used to update badge counts after each refresh.
+/// The window system — used to write to the top panel.
+/// Returns the currently active event store name, or for the default.
+/// Returns the currently active namespace name, or for the default.
+public class WorkbenchRefreshLoop(
+ WorkbenchDataService dataService,
+ WorkbenchSettings settings,
+ IWorkbenchView[] views,
+ WorkbenchNavigation navigation,
+ ConsoleWindowSystem windowSystem,
+ Func getActiveEventStore,
+ Func getActiveNamespace)
+{
+ readonly object _dataLock = new();
+ WorkbenchData? _currentData;
+ bool _wasDisconnected;
+
+ ///
+ /// Gets the most recently fetched snapshot, or if no fetch has completed yet.
+ /// Thread-safe — acquires the internal data lock on every access.
+ ///
+ public WorkbenchData? CurrentData
+ {
+ get
+ {
+ lock (_dataLock)
+ {
+ return _currentData;
+ }
+ }
+ }
+
+ ///
+ /// Seeds the loop with a pre-fetched snapshot: pushes it to all views, updates the top panel, and
+ /// updates navigation badge counts. Call once, before the window is shown.
+ ///
+ /// The pre-fetched initial snapshot.
+ public void Initialize(WorkbenchData data)
+ {
+ lock (_dataLock)
+ {
+ _currentData = data;
+ }
+
+ PushDataToViews(data);
+ UpdateTopPanel(data);
+ navigation.UpdateNavBadges(data);
+ }
+
+ ///
+ /// Runs the periodic refresh loop as a SharpConsoleUI async window thread.
+ /// Performs an immediate fetch on entry, then repeats on the configured interval until cancellation.
+ ///
+ /// The host window (unused — required by the window-thread delegate signature).
+ /// Cancellation token that signals the window is closing.
+ public async Task RunAsync(Window window, CancellationToken ct)
+ {
+ await FetchAndUpdate(ct);
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(settings.Interval), ct);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ await FetchAndUpdate(ct);
+ }
+ }
+
+ ///
+ /// Fetches a fresh snapshot and distributes it to all views, the top panel, and navigation badges.
+ ///
+ /// Cancellation token.
+ public async Task FetchAndUpdate(CancellationToken ct)
+ {
+ try
+ {
+ SetPanelText("↻ refreshing…");
+
+ var data = await dataService.FetchAsync(
+ getActiveEventStore(),
+ getActiveNamespace(),
+ readModelContainerName: null,
+ ct);
+
+ lock (_dataLock)
+ {
+ _currentData = data;
+ }
+
+ PushDataToViews(data);
+
+ if (_wasDisconnected && data.IsConnected)
+ {
+ _ = Task.Run(
+ async () =>
+ {
+ SetPanelText("✓ Reconnected");
+ await Task.Delay(3000, ct);
+ UpdateTopPanel(_currentData);
+ },
+ ct);
+ }
+
+ _wasDisconnected = !data.IsConnected;
+
+ UpdateTopPanel(data);
+ navigation.UpdateNavBadges(data);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch
+ {
+ // Swallow — connectivity errors are surfaced via IsConnected on the next successful fetch.
+ }
+ }
+
+ ///
+ /// Displays a temporary message in the top panel, then resets to the normal connection summary after two seconds.
+ ///
+ /// The temporary text to display.
+ public void ShowTemporaryMessage(string text)
+ {
+ SetPanelText(text);
+ _ = Task.Delay(2000).ContinueWith(_ => UpdateTopPanel(CurrentData), TaskScheduler.Default);
+ }
+
+ ///
+ /// Updates the top panel with the current connection / store / namespace summary.
+ /// Falls back to when is .
+ ///
+ /// Optional snapshot to use; falls back to when omitted.
+ public void UpdateTopPanel(WorkbenchData? data = null)
+ {
+ data ??= CurrentData;
+ if (data is null)
+ {
+ return;
+ }
+
+ var host = ExtractHostFromConnectionString(settings.ResolveConnectionString());
+ var eventStore = getActiveEventStore() ?? settings.ResolveEventStore();
+ var ns = getActiveNamespace() ?? settings.ResolveNamespace();
+ var connDot = data.IsConnected ? "●" : "○";
+ var seqText = data.TailSequenceNumber.HasValue ? $" seq#{data.TailSequenceNumber.Value:N0}" : string.Empty;
+
+ windowSystem.PanelStateService.TopStatus =
+ $"◆ CHRONICLE WORKBENCH · {host} · {eventStore}/{ns} · ↻{settings.Interval}s{seqText} {connDot}";
+ }
+
+ static string ExtractHostFromConnectionString(string connectionString)
+ {
+ const string scheme = "chronicle://";
+ if (!connectionString.StartsWith(scheme, StringComparison.OrdinalIgnoreCase))
+ {
+ return connectionString;
+ }
+
+ var afterScheme = connectionString[scheme.Length..];
+
+ var queryStart = afterScheme.IndexOf('?');
+ if (queryStart >= 0)
+ {
+ afterScheme = afterScheme[..queryStart];
+ }
+
+ var atSign = afterScheme.IndexOf('@');
+ if (atSign >= 0)
+ {
+ afterScheme = afterScheme[(atSign + 1)..];
+ }
+
+ return $"chronicle://{afterScheme}";
+ }
+
+ void PushDataToViews(WorkbenchData data)
+ {
+ foreach (var view in views)
+ {
+ view.UpdateData(data);
+ }
+ }
+
+ void SetPanelText(string text) =>
+ windowSystem.PanelStateService.TopStatus = text;
+}