fix: исправить навигацию клавиатуры в TUI через GlobalKeyboardService

- Добавить GlobalKeyboardService — выделенный поток с блокирующим
  Console.ReadKey, единственный источник клавишных событий для TUI
- Убрать FocusManager из App.razor: перехватывал Tab до компонентов
- Удалить @onkeydown с <Select>: RazorConsole не пробрасывает Tab/стрелки
  через этот механизм
- Использовать FocusedValue вместо Value в Select для корректной подсветки
- Обновить CLAUDE.md и AGENTS.md: архитектура TUI, RazorConsole gotchas
- Добавить docs/tui_log.md: разбор проблемы и справочник по RazorConsole

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 23:31:53 +03:00
parent 4bf267d681
commit 9b27cd7dc2
18 changed files with 1244 additions and 291 deletions

View File

@@ -42,11 +42,14 @@
- Read related files before editing. - Read related files before editing.
- Prefer minimal, non-breaking changes. - Prefer minimal, non-breaking changes.
- Reuse existing patterns; avoid new abstractions without clear need. - Reuse existing patterns; avoid new abstractions without clear need.
- Match the style of the file being edited (naming, formatting, tone, language).
- Verify behavior against code and config, not `README.md`. - Verify behavior against code and config, not `README.md`.
- After changes, run `dotnet build`. If MCP wiring changed, also run the inspector. - After changes, run `dotnet build`. If MCP wiring changed, also run the inspector.
- Output in Russian. Keep code in English. Keep comments and commit messages in Russian. - Output in Russian. Keep code in English. Keep comments and commit messages in Russian.
- If the request is broad or underspecified, ask one short clarifying question first. Otherwise act on the best reasonable assumption. - If the request is broad or underspecified, ask one short clarifying question first. Otherwise act on the best reasonable assumption.
- Project file structure and metadata indexed in memory via MCP memory system. - Project file structure and metadata indexed in memory via MCP memory system.
### OpenCode: Question First ### Documentation
- Follow `docs/opencode/question-policy.md` for the detailed `question` usage policy. - RazorConsole gotchas and TUI keyboard notes: `docs/tui_log.md`. Read before touching TUI or input handling.
- RazorConsole library docs: `docs/razorconsole/` (`overview.md`, `components.md`, `custom-translators.md`).
- OpenCode question policy: `docs/opencode/question-policy.md`.

View File

@@ -32,7 +32,7 @@ LazyBear is a .NET 10 MCP server exposing Jira, Confluence, and Kubernetes integ
1. **TUI (foreground)** — RazorConsole terminal UI (`App.razor`), owns the console, handles keyboard navigation 1. **TUI (foreground)** — RazorConsole terminal UI (`App.razor`), owns the console, handles keyboard navigation
2. **HTTP MCP endpoint (background)**`McpWebHostedService` runs as a hosted service, serves MCP tool calls on port 5000 2. **HTTP MCP endpoint (background)**`McpWebHostedService` runs as a hosted service, serves MCP tool calls on port 5000
Both share the same singletons: `ToolRegistryService`, `InMemoryLogSink`, and the three client providers. Both share the same singletons: `ToolRegistryService`, `InMemoryLogSink`, `GlobalKeyboardService`, and the three client providers.
### Plugin System (IToolModule) ### Plugin System (IToolModule)
@@ -50,6 +50,12 @@ Each client (`K8sClientProvider`, `JiraClientProvider`, `ConfluenceClientProvide
`InMemoryLogSink` maintains a 500-entry circular `ConcurrentQueue`. All .NET logs flow through `InMemoryLoggerProvider` → sink → `OnLog` event → TUI Logs tab live view. `InMemoryLogSink` maintains a 500-entry circular `ConcurrentQueue`. All .NET logs flow through `InMemoryLoggerProvider` → sink → `OnLog` event → TUI Logs tab live view.
### TUI Keyboard Input
`GlobalKeyboardService` (`TUI/GlobalKeyboardService.cs`) is the **single source of keyboard events** for the entire TUI. It runs a dedicated background thread with a blocking `Console.ReadKey(intercept: true)` — no polling. `App.razor` subscribes to `OnKeyPressed`, converts `ConsoleKeyInfo` to `KeyboardEventArgs`, and dispatches via `InvokeAsync`.
Do **not** use `@onkeydown` on `<Select>` or other RazorConsole components for navigation logic — the framework intercepts Tab and arrow keys internally before they reach component-level callbacks. See `docs/tui_log.md` for the full breakdown.
### TUI Navigation ### TUI Navigation
Tabs: Overview → Logs → Settings (switch with `Tab`/`Shift+Tab`). In Settings: arrow keys navigate the module→tool tree, `Space` toggles enable/disable, `Enter` expands/collapses. In Overview, `Enter` on a module jumps to its Settings entry. Tabs: Overview → Logs → Settings (switch with `Tab`/`Shift+Tab`). In Settings: arrow keys navigate the module→tool tree, `Space` toggles enable/disable, `Enter` expands/collapses. In Overview, `Enter` on a module jumps to its Settings entry.
@@ -60,6 +66,9 @@ Tabs: Overview → Logs → Settings (switch with `Tab`/`Shift+Tab`). In Setting
- **K8s kubeconfig fallback order:** explicit `Kubernetes:KubeconfigPath``~/.kube/config` → in-cluster config - **K8s kubeconfig fallback order:** explicit `Kubernetes:KubeconfigPath``~/.kube/config` → in-cluster config
- **Source of truth:** `Program.cs`, not `README.md` (README is aspirational) - **Source of truth:** `Program.cs`, not `README.md` (README is aspirational)
- `Pages/` directory exists but Razor Pages are **not enabled** in `Program.cs` — do not use them - `Pages/` directory exists but Razor Pages are **not enabled** in `Program.cs` — do not use them
- **RazorConsole keyboard gotchas:** `@onkeydown` on interactive components doesn't propagate Tab/arrows; `FocusManager` intercepts Tab globally; there is no public global key-intercept API. Full notes: `docs/tui_log.md`
- **`GlobalKeyboardService` registration pattern** — registered as both singleton and hosted service so it can be injected into Razor components: `services.AddSingleton<T>()` + `services.AddHostedService(sp => sp.GetRequiredService<T>())`
- **`Console.KeyAvailable` polling causes rendering lag** — it acquires the console mutex on every call and competes with RazorConsole's renderer. Always use blocking `Console.ReadKey` in a dedicated thread instead
## Key Dependencies ## Key Dependencies

View File

@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseAppHost>false</UseAppHost>
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC --> <!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
<AddRazorSupportForMvc>false</AddRazorSupportForMvc> <AddRazorSupportForMvc>false</AddRazorSupportForMvc>
</PropertyGroup> </PropertyGroup>

View File

@@ -2,40 +2,87 @@ using LazyBear.MCP.Services.Confluence;
using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Jira;
using LazyBear.MCP.Services.Kubernetes; using LazyBear.MCP.Services.Kubernetes;
using LazyBear.MCP.Services.Logging; using LazyBear.MCP.Services.Logging;
using LazyBear.MCP.Services.Mcp;
using LazyBear.MCP.Services.ToolRegistry; using LazyBear.MCP.Services.ToolRegistry;
using LazyBear.MCP.TUI; using LazyBear.MCP.TUI;
using LazyBear.MCP.TUI.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RazorConsole.Core;
var builder = WebApplication.CreateBuilder(args); // ── Общий логгер и один DI-контейнер для TUI + MCP ──────────────────────────
// ── InMemoryLogSink: регистрируем Singleton и кастомный логгер ───────────────
var logSink = new InMemoryLogSink(); var logSink = new InMemoryLogSink();
builder.Services.AddSingleton(logSink);
builder.Logging.AddProvider(new InMemoryLoggerProvider(logSink));
// ── MCP-провайдеры ─────────────────────────────────────────────────────────── var host = Host.CreateDefaultBuilder(args)
builder.Services.AddSingleton<K8sClientProvider>(); .ConfigureServices(services =>
builder.Services.AddSingleton<JiraClientProvider>(); {
builder.Services.AddSingleton<ConfluenceClientProvider>(); services.AddSingleton(logSink);
services.AddSingleton<ToolRegistryService>();
// ── ToolRegistry ───────────────────────────────────────────────────────────── // MCP-провайдеры
builder.Services.AddSingleton<ToolRegistryService>(); services.AddSingleton<K8sClientProvider>();
services.AddSingleton<JiraClientProvider>();
services.AddSingleton<ConfluenceClientProvider>();
// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI) // Модули инструментов (добавь новый IToolModule — он появится в TUI)
builder.Services.AddSingleton<IToolModule, JiraToolModule>(); services.AddSingleton<IToolModule, JiraToolModule>();
builder.Services.AddSingleton<IToolModule, KubernetesToolModule>(); services.AddSingleton<IToolModule, KubernetesToolModule>();
builder.Services.AddSingleton<IToolModule, ConfluenceToolModule>(); services.AddSingleton<IToolModule, ConfluenceToolModule>();
// ── MCP-сервер ─────────────────────────────────────────────────────────────── // HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
builder.Services.AddMcpServer() services.AddHostedService<McpWebHostedService>();
.WithHttpTransport()
.WithToolsFromAssembly();
// ── TUI как фоновый сервис ─────────────────────────────────────────────────── // Глобальный читатель клавиш — единственный источник клавишных событий для TUI
builder.Services.AddHostedService<TuiHostedService>(); services.AddSingleton<GlobalKeyboardService>();
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
})
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddProvider(new InMemoryLoggerProvider(logSink));
})
.UseRazorConsole<App>(hostBuilder =>
{
hostBuilder.ConfigureServices(services =>
{
services.Configure<ConsoleAppOptions>(options =>
{
options.AutoClearConsole = true;
options.EnableTerminalResizing = true;
options.AfterRenderAsync = (_, _, _) =>
{
try
{
Console.CursorVisible = false;
}
catch
{
// Ignore terminals that do not support CursorVisible.
}
var app = builder.Build(); try
{
Console.Write("\u001b[?25l");
Console.Out.Flush();
}
catch
{
// Ignore terminals that do not support ANSI cursor control.
}
app.MapMcp(); return Task.CompletedTask;
};
});
});
})
.Build();
var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000"; // ── Регистрируем модули один раз до старта TUI и web host ───────────────────
app.Run(urls); var registry = host.Services.GetRequiredService<ToolRegistryService>();
foreach (var module in host.Services.GetServices<IToolModule>())
{
registry.RegisterModule(module);
}
await host.RunAsync();

View File

@@ -0,0 +1,66 @@
using LazyBear.MCP.Services.Confluence;
using LazyBear.MCP.Services.Jira;
using LazyBear.MCP.Services.Kubernetes;
using LazyBear.MCP.Services.Logging;
using LazyBear.MCP.Services.ToolRegistry;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LazyBear.MCP.Services.Mcp;
/// <summary>
/// Поднимает HTTP MCP endpoint в фоне, не вмешиваясь в основной TUI event loop.
/// Использует общие singleton-экземпляры из root host.
/// </summary>
public sealed class McpWebHostedService(
IServiceProvider rootServices,
IConfiguration configuration,
ILogger<McpWebHostedService> logger) : IHostedService
{
private WebApplication? _webApp;
public async Task StartAsync(CancellationToken cancellationToken)
{
var builder = WebApplication.CreateBuilder();
var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000";
builder.WebHost.UseUrls(urls);
// Используем тот же IConfiguration и те же singleton-сервисы, что и в TUI host.
builder.Services.AddSingleton(configuration);
builder.Services.AddSingleton(rootServices.GetRequiredService<InMemoryLogSink>());
builder.Services.AddSingleton(rootServices.GetRequiredService<ToolRegistryService>());
builder.Services.AddSingleton(rootServices.GetRequiredService<K8sClientProvider>());
builder.Services.AddSingleton(rootServices.GetRequiredService<JiraClientProvider>());
builder.Services.AddSingleton(rootServices.GetRequiredService<ConfluenceClientProvider>());
foreach (var module in rootServices.GetServices<IToolModule>())
{
builder.Services.AddSingleton(module);
builder.Services.AddSingleton(typeof(IToolModule), module);
}
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
_webApp = builder.Build();
_webApp.MapMcp();
await _webApp.StartAsync(cancellationToken);
logger.LogInformation("HTTP MCP endpoint запущен на {Urls}", urls);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_webApp is null)
{
return;
}
await _webApp.StopAsync(cancellationToken);
await _webApp.DisposeAsync();
_webApp = null;
}
}

View File

@@ -49,9 +49,12 @@ public sealed class ToolRegistryService
public bool IsModuleEnabled(string moduleName) => public bool IsModuleEnabled(string moduleName) =>
_moduleEnabled.GetValueOrDefault(moduleName, true); _moduleEnabled.GetValueOrDefault(moduleName, true);
public bool IsToolConfiguredEnabled(string moduleName, string toolName) =>
_toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true);
public bool IsToolEnabled(string moduleName, string toolName) => public bool IsToolEnabled(string moduleName, string toolName) =>
IsModuleEnabled(moduleName) && IsModuleEnabled(moduleName) &&
_toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true); IsToolConfiguredEnabled(moduleName, toolName);
// ── Переключение ───────────────────────────────────────────────────────── // ── Переключение ─────────────────────────────────────────────────────────
@@ -71,7 +74,7 @@ public sealed class ToolRegistryService
SetModuleEnabled(moduleName, !IsModuleEnabled(moduleName)); SetModuleEnabled(moduleName, !IsModuleEnabled(moduleName));
public void ToggleTool(string moduleName, string toolName) => public void ToggleTool(string moduleName, string toolName) =>
SetToolEnabled(moduleName, toolName, !IsToolEnabled(moduleName, toolName)); SetToolEnabled(moduleName, toolName, !IsToolConfiguredEnabled(moduleName, toolName));
// ── Счётчики для Overview ───────────────────────────────────────────────── // ── Счётчики для Overview ─────────────────────────────────────────────────
@@ -90,6 +93,21 @@ public sealed class ToolRegistryService
} }
} }
public (int Enabled, int Total) GetConfiguredToolCounts(string moduleName)
{
lock (_modulesLock)
{
var module = _modules.FirstOrDefault(m =>
string.Equals(m.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase));
if (module is null) return (0, 0);
var total = module.ToolNames.Count;
var enabled = module.ToolNames.Count(t => IsToolConfiguredEnabled(moduleName, t));
return (enabled, total);
}
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static string MakeKey(string module, string tool) => $"{module}::{tool}"; private static string MakeKey(string module, string tool) => $"{module}::{tool}";

View File

@@ -2,80 +2,542 @@
@using LazyBear.MCP.Services.ToolRegistry @using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry @inject ToolRegistryService Registry
@inject InMemoryLogSink LogSink @inject InMemoryLogSink LogSink
@inject GlobalKeyboardService KeyboardService
@implements IDisposable @implements IDisposable
<Rows> <Rows Expand="true">
<Panel Title="LazyBear MCP" BorderColor="@Spectre.Console.Color.Gold1" Expand="true"> <Panel Title="LazyBear MCP"
<Rows> TitleColor="@UiPalette.Accent"
@* Таб-навигация *@ BorderColor="@UiPalette.Frame"
Expand="true"
Height="@GetPanelHeight()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows Expand="true">
<Markup Content="Tab: switch tabs | Arrows: navigate | Space: toggle | Enter: open" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns> <Columns>
<TextButton Content="[1] Overview" @foreach (var tab in _tabs)
OnClick="@(() => SetTab(Tab.Overview))" {
BackgroundColor="@(_activeTab == Tab.Overview ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)" var isActive = _activeTab == tab;
FocusedColor="@Spectre.Console.Color.Blue" <Markup Content="@($" {GetTabLabel(tab)} ")"
FocusOrder="1" /> Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
<TextButton Content="[2] Logs" Background="@(isActive ? UiPalette.Accent : UiPalette.SurfaceMuted)"
OnClick="@(() => SetTab(Tab.Logs))" Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
BackgroundColor="@(_activeTab == Tab.Logs ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)" <Markup Content=" " />
FocusedColor="@Spectre.Console.Color.Blue" }
FocusOrder="2" />
<TextButton Content="[3] Settings"
OnClick="@(() => SetTab(Tab.Settings))"
BackgroundColor="@(_activeTab == Tab.Settings ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="3" />
</Columns> </Columns>
@* Контент таба *@ <Markup Content=" " />
@if (_activeTab == Tab.Overview) @if (_activeTab == Tab.Overview)
{ {
<OverviewTab /> <OverviewTab Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()" />
} }
else if (_activeTab == Tab.Logs) else if (_activeTab == Tab.Logs)
{ {
<LogsTab /> <LogsTab Entries="@GetFilteredLogEntries()"
SelectedIndex="@_logSelection"
SelectedIndexChanged="@OnLogSelectionChanged"
SelectedFilter="@_logFilters[_logFilterIndex]"
ViewportRows="@GetLogsViewportRows()"
IsStickyToBottom="@_logsStickToBottom" />
} }
else else
{ {
<SettingsTab /> <SettingsTab Entries="@GetSettingsEntries()"
SelectedIndex="@_settingsSelection"
SelectedIndexChanged="@OnSettingsSelectionChanged"
ViewportRows="@GetSettingsViewportRows()" />
} }
</Rows> </Rows>
</Panel> </Panel>
</Rows> </Rows>
@code { @code {
private enum Tab { Overview, Logs, Settings } private enum Tab
{
Overview,
Logs,
Settings
}
private static readonly Tab[] _tabs = [Tab.Overview, Tab.Logs, Tab.Settings];
private static readonly string[] _logFilters = ["All", "Info", "Warn", "Error"];
private readonly HashSet<string> _expandedModules = new(StringComparer.Ordinal);
private Tab _activeTab = Tab.Overview; private Tab _activeTab = Tab.Overview;
private int _overviewSelection;
private int _logFilterIndex;
private int _logSelection;
private int _settingsSelection;
private bool _logsStickToBottom = true;
private static int GetPanelHeight() => Math.Max(Console.WindowHeight - 2, 10);
private static int GetOverviewViewportRows() => Math.Max(Console.WindowHeight - 11, 3);
private static int GetLogsViewportRows() => Math.Max(Console.WindowHeight - 16, 5);
private static int GetSettingsViewportRows() => Math.Max(Console.WindowHeight - 13, 5);
protected override void OnInitialized() protected override void OnInitialized()
{ {
Registry.StateChanged += OnStateChanged; Registry.StateChanged += OnRegistryChanged;
LogSink.OnLog += OnNewLog; LogSink.OnLog += OnNewLog;
KeyboardService.OnKeyPressed += OnConsoleKeyPressed;
} }
private void SetTab(Tab tab) // Конвертация ConsoleKeyInfo → KeyboardEventArgs для переиспользования существующей логики
private static KeyboardEventArgs ConvertKey(ConsoleKeyInfo key)
{ {
_activeTab = tab; var name = key.Key switch
{
ConsoleKey.UpArrow => "ArrowUp",
ConsoleKey.DownArrow => "ArrowDown",
ConsoleKey.LeftArrow => "ArrowLeft",
ConsoleKey.RightArrow => "ArrowRight",
ConsoleKey.Enter => "Enter",
ConsoleKey.Spacebar => " ",
ConsoleKey.Home => "Home",
ConsoleKey.End => "End",
ConsoleKey.PageUp => "PageUp",
ConsoleKey.PageDown => "PageDown",
ConsoleKey.Tab => "Tab",
_ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString()
};
return new KeyboardEventArgs
{
Key = name,
ShiftKey = (key.Modifiers & ConsoleModifiers.Shift) != 0
};
}
private void OnConsoleKeyPressed(ConsoleKeyInfo key)
{
var args = ConvertKey(key);
if (string.IsNullOrEmpty(args.Key))
{
return;
}
InvokeAsync(() =>
{
HandleKeyDown(args);
StateHasChanged(); StateHasChanged();
});
} }
private void OnStateChanged() private void HandleKeyDown(KeyboardEventArgs args)
{ {
InvokeAsync(StateHasChanged); if (string.Equals(args.Key, "Tab", StringComparison.Ordinal))
{
ChangeTab(args.ShiftKey ? -1 : 1);
return;
} }
private void OnNewLog(LogEntry _) switch (_activeTab)
{ {
if (_activeTab == Tab.Logs) case Tab.Overview:
{ HandleOverviewKey(args);
InvokeAsync(StateHasChanged); break;
case Tab.Logs:
HandleLogsKey(args);
break;
case Tab.Settings:
HandleSettingsKey(args);
break;
} }
} }
private Task OnOverviewSelectionChanged(int value)
{
var rows = GetOverviewRows();
_overviewSelection = rows.Count == 0 ? 0 : Math.Clamp(value, 0, rows.Count - 1);
return Task.CompletedTask;
}
private Task OnLogSelectionChanged(int value)
{
var entries = GetFilteredLogEntries();
_logSelection = entries.Count == 0 ? 0 : Math.Clamp(value, 0, entries.Count - 1);
_logsStickToBottom = entries.Count == 0 || _logSelection >= entries.Count - 1;
return Task.CompletedTask;
}
private Task OnSettingsSelectionChanged(int value)
{
var entries = GetSettingsEntries();
_settingsSelection = entries.Count == 0 ? 0 : Math.Clamp(value, 0, entries.Count - 1);
return Task.CompletedTask;
}
private void ChangeTab(int step)
{
var currentIndex = Array.IndexOf(_tabs, _activeTab);
if (currentIndex < 0)
{
currentIndex = 0;
}
var nextIndex = (currentIndex + step + _tabs.Length) % _tabs.Length;
_activeTab = _tabs[nextIndex];
ClampSelections();
}
private void HandleOverviewKey(KeyboardEventArgs args)
{
var rows = GetOverviewRows();
if (rows.Count == 0)
{
_overviewSelection = 0;
return;
}
_overviewSelection = Math.Clamp(_overviewSelection, 0, rows.Count - 1);
switch (args.Key)
{
case "ArrowUp":
_overviewSelection = Math.Max(0, _overviewSelection - 1);
break;
case "ArrowDown":
_overviewSelection = Math.Min(rows.Count - 1, _overviewSelection + 1);
break;
case "Home":
_overviewSelection = 0;
break;
case "End":
_overviewSelection = rows.Count - 1;
break;
case "Enter":
_activeTab = Tab.Settings;
SelectSettingsModule(rows[_overviewSelection].ModuleName);
break;
}
}
private void HandleLogsKey(KeyboardEventArgs args)
{
switch (args.Key)
{
case "ArrowLeft":
_logFilterIndex = (_logFilterIndex - 1 + _logFilters.Length) % _logFilters.Length;
ResetLogsSelectionToBottom();
return;
case "ArrowRight":
_logFilterIndex = (_logFilterIndex + 1) % _logFilters.Length;
ResetLogsSelectionToBottom();
return;
}
var entries = GetFilteredLogEntries();
if (entries.Count == 0)
{
_logSelection = 0;
_logsStickToBottom = true;
return;
}
_logSelection = Math.Clamp(_logSelection, 0, entries.Count - 1);
var page = Math.Max(GetLogsViewportRows() - 1, 1);
switch (args.Key)
{
case "ArrowUp":
_logSelection = Math.Max(0, _logSelection - 1);
break;
case "ArrowDown":
_logSelection = Math.Min(entries.Count - 1, _logSelection + 1);
break;
case "PageUp":
_logSelection = Math.Max(0, _logSelection - page);
break;
case "PageDown":
case " ":
case "Spacebar":
_logSelection = Math.Min(entries.Count - 1, _logSelection + page);
break;
case "Home":
_logSelection = 0;
break;
case "End":
_logSelection = entries.Count - 1;
break;
}
_logsStickToBottom = _logSelection >= entries.Count - 1;
}
private void HandleSettingsKey(KeyboardEventArgs args)
{
var entries = GetSettingsEntries();
if (entries.Count == 0)
{
_settingsSelection = 0;
return;
}
_settingsSelection = Math.Clamp(_settingsSelection, 0, entries.Count - 1);
var selected = entries[_settingsSelection];
var page = Math.Max(GetSettingsViewportRows() - 1, 1);
switch (args.Key)
{
case "ArrowUp":
_settingsSelection = Math.Max(0, _settingsSelection - 1);
return;
case "ArrowDown":
_settingsSelection = Math.Min(entries.Count - 1, _settingsSelection + 1);
return;
case "PageUp":
_settingsSelection = Math.Max(0, _settingsSelection - page);
return;
case "PageDown":
_settingsSelection = Math.Min(entries.Count - 1, _settingsSelection + page);
return;
case "Home":
_settingsSelection = 0;
return;
case "End":
_settingsSelection = entries.Count - 1;
return;
case "ArrowRight":
ExpandModule(selected);
return;
case "ArrowLeft":
CollapseModuleOrFocusParent(selected);
return;
case "Enter":
ToggleExpansion(selected);
return;
case " ":
case "Spacebar":
ToggleSetting(selected);
return;
}
}
private void ExpandModule(SettingsEntry entry)
{
if (entry.Kind != SettingsEntryKind.Module || entry.IsExpanded)
{
return;
}
_expandedModules.Add(entry.ModuleName);
ClampSelections();
}
private void CollapseModuleOrFocusParent(SettingsEntry entry)
{
if (entry.Kind == SettingsEntryKind.Module)
{
if (_expandedModules.Remove(entry.ModuleName))
{
ClampSelections();
}
return;
}
_expandedModules.Remove(entry.ModuleName);
SelectSettingsModule(entry.ModuleName);
}
private void ToggleExpansion(SettingsEntry entry)
{
if (entry.Kind != SettingsEntryKind.Module)
{
ToggleSetting(entry);
return;
}
if (_expandedModules.Contains(entry.ModuleName))
{
_expandedModules.Remove(entry.ModuleName);
}
else
{
_expandedModules.Add(entry.ModuleName);
}
SelectSettingsModule(entry.ModuleName);
}
private void ToggleSetting(SettingsEntry entry)
{
if (entry.Kind == SettingsEntryKind.Module)
{
Registry.ToggleModule(entry.ModuleName);
return;
}
if (!string.IsNullOrWhiteSpace(entry.ToolName))
{
Registry.ToggleTool(entry.ModuleName, entry.ToolName);
}
}
private void SelectSettingsModule(string moduleName)
{
var entries = GetSettingsEntries();
var index = entries.FindIndex(entry =>
entry.Kind == SettingsEntryKind.Module &&
string.Equals(entry.ModuleName, moduleName, StringComparison.Ordinal));
_settingsSelection = index >= 0 ? index : 0;
}
private void ResetLogsSelectionToBottom()
{
var entries = GetFilteredLogEntries();
_logSelection = Math.Max(entries.Count - 1, 0);
_logsStickToBottom = true;
}
private List<OverviewRow> GetOverviewRows() =>
Registry.GetModules()
.Select(module =>
{
var (configuredTools, totalTools) = Registry.GetConfiguredToolCounts(module.ModuleName);
return new OverviewRow(
module.ModuleName,
module.Description,
Registry.IsModuleEnabled(module.ModuleName),
configuredTools,
totalTools);
})
.ToList();
private List<SettingsEntry> GetSettingsEntries()
{
var entries = new List<SettingsEntry>();
foreach (var module in Registry.GetModules())
{
var isModuleEnabled = Registry.IsModuleEnabled(module.ModuleName);
var isExpanded = _expandedModules.Contains(module.ModuleName);
entries.Add(new SettingsEntry(
SettingsEntryKind.Module,
module.ModuleName,
null,
module.ModuleName,
module.Description,
isModuleEnabled,
isModuleEnabled,
isExpanded,
0));
if (!isExpanded)
{
continue;
}
foreach (var toolName in module.ToolNames)
{
var isConfigured = Registry.IsToolConfiguredEnabled(module.ModuleName, toolName);
entries.Add(new SettingsEntry(
SettingsEntryKind.Tool,
module.ModuleName,
toolName,
toolName,
isModuleEnabled
? $"{module.ModuleName} / {toolName}"
: $"{module.ModuleName} / {toolName} (module is OFF, tool state is preserved)",
isConfigured,
isModuleEnabled,
false,
1));
}
}
return entries;
}
private List<LogEntry> GetFilteredLogEntries()
{
var entries = LogSink.GetEntries();
return _logFilters[_logFilterIndex] switch
{
"Info" => entries.Where(IsInfoLevel).ToList(),
"Warn" => entries.Where(entry => entry.Level == LogLevel.Warning).ToList(),
"Error" => entries.Where(entry => entry.Level is LogLevel.Error or LogLevel.Critical).ToList(),
_ => entries.ToList()
};
}
private void ClampSelections()
{
var overviewRows = GetOverviewRows();
_overviewSelection = overviewRows.Count == 0
? 0
: Math.Clamp(_overviewSelection, 0, overviewRows.Count - 1);
var logEntries = GetFilteredLogEntries();
_logSelection = logEntries.Count == 0
? 0
: Math.Clamp(_logSelection, 0, logEntries.Count - 1);
var settingsEntries = GetSettingsEntries();
_settingsSelection = settingsEntries.Count == 0
? 0
: Math.Clamp(_settingsSelection, 0, settingsEntries.Count - 1);
}
private void OnRegistryChanged()
{
InvokeAsync(() =>
{
ClampSelections();
StateHasChanged();
});
}
private void OnNewLog(LogEntry entry)
{
InvokeAsync(() =>
{
if (_logsStickToBottom && MatchesCurrentLogFilter(entry))
{
var filteredEntries = GetFilteredLogEntries();
_logSelection = Math.Max(filteredEntries.Count - 1, 0);
}
else
{
ClampSelections();
}
StateHasChanged();
});
}
private bool MatchesCurrentLogFilter(LogEntry entry) =>
_logFilters[_logFilterIndex] switch
{
"Info" => IsInfoLevel(entry),
"Warn" => entry.Level == LogLevel.Warning,
"Error" => entry.Level is LogLevel.Error or LogLevel.Critical,
_ => true
};
private static bool IsInfoLevel(LogEntry entry) =>
entry.Level is LogLevel.Information or LogLevel.Debug or LogLevel.Trace;
private static string GetTabLabel(Tab tab) => tab switch
{
Tab.Overview => "Overview",
Tab.Logs => "Logs",
_ => "Settings"
};
public void Dispose() public void Dispose()
{ {
Registry.StateChanged -= OnStateChanged; Registry.StateChanged -= OnRegistryChanged;
LogSink.OnLog -= OnNewLog; LogSink.OnLog -= OnNewLog;
KeyboardService.OnKeyPressed -= OnConsoleKeyPressed;
} }
} }

View File

@@ -1,49 +1,101 @@
@using LazyBear.MCP.Services.Logging @using LazyBear.MCP.Services.Logging
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows> <Rows>
<Markup Content="Runtime Logs" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="Left/Right change level. Up/Down move. PageUp/PageDown/Home/End scroll." Foreground="@UiPalette.TextMuted" />
<Markup Content=" " /> <Markup Content=" " />
@* Фильтр по модулю *@
<Columns> <Columns>
<Markup Content="Filter: " /> @foreach (var filter in Filters)
<Select TItem="string" {
Options="@_filterOptions" var isActive = string.Equals(filter, SelectedFilter, StringComparison.Ordinal);
Value="@_selectedFilter" <Markup Content="@($" {filter} ")"
ValueChanged="@OnFilterChanged" Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
FocusOrder="10" /> Background="@(isActive ? UiPalette.AccentSoft : UiPalette.SurfaceMuted)"
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
<Markup Content=" " />
}
</Columns> </Columns>
<Markup Content=" " /> <Markup Content=" " />
@{ @if (Entries.Count == 0)
var entries = GetFilteredEntries();
}
@if (entries.Count == 0)
{ {
<Markup Content="[grey]No log entries yet...[/]" /> <Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
<Markup Content="No log entries yet." Foreground="@UiPalette.TextDim" />
</Border>
} }
else else
{ {
@* Показываем последние 20 строк с прокруткой *@ <Select TItem="int"
<ViewHeightScrollable LinesToRender="20" Options="@GetOptions()"
ScrollOffset="@_scrollOffset" Value="@GetNormalizedIndex()"
ScrollOffsetChanged="@(v => { _scrollOffset = v; })" > FocusedValue="@GetNormalizedIndex()"
<Rows> Formatter="@FormatEntry"
@foreach (var entry in entries) Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " />
<Markup Content="@GetDetailsHeader()" Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetDetailsText()" Foreground="@UiPalette.Text" />
</Rows>
@code {
private static readonly string[] Filters = ["All", "Info", "Warn", "Error"];
[Parameter, EditorRequired] public IReadOnlyList<LogEntry> Entries { get; set; } = Array.Empty<LogEntry>();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public string SelectedFilter { get; set; } = "All";
[Parameter] public int ViewportRows { get; set; } = 5;
[Parameter] public bool IsStickyToBottom { get; set; }
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
private string GetDetailsHeader()
{ {
var levelColor = entry.Level switch if (Entries.Count == 0)
{ {
LogLevel.Error or LogLevel.Critical => Spectre.Console.Color.Red, return $"Filter: {SelectedFilter}";
LogLevel.Warning => Spectre.Console.Color.Yellow, }
LogLevel.Information => Spectre.Console.Color.White,
_ => Spectre.Console.Color.Grey var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
var position = Math.Clamp(SelectedIndex, 0, Entries.Count - 1) + 1;
var sticky = IsStickyToBottom ? "sticky" : "manual";
return $"{position}/{Entries.Count} | {selected.Timestamp:HH:mm:ss} | {selected.Level} | {selected.ShortCategory} | {sticky}";
}
private string GetDetailsText()
{
if (Entries.Count == 0)
{
return "Incoming log entries will appear here.";
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
var details = string.IsNullOrWhiteSpace(selected.Exception)
? selected.Message
: $"{selected.Message} | {selected.Exception}";
return Fit(details, Math.Max(Console.WindowWidth - 12, 32));
}
private static Spectre.Console.Color GetLevelColor(LogEntry entry) => entry.Level switch
{
LogLevel.Error or LogLevel.Critical => UiPalette.Danger,
LogLevel.Warning => UiPalette.Warning,
LogLevel.Information => UiPalette.Text,
_ => UiPalette.TextMuted
}; };
var levelTag = entry.Level switch private string FormatEntry(int index)
{
var entry = Entries[index];
var level = entry.Level switch
{ {
LogLevel.Error => "ERR", LogLevel.Error => "ERR",
LogLevel.Critical => "CRT", LogLevel.Critical => "CRT",
@@ -53,67 +105,27 @@
_ => "TRC" _ => "TRC"
}; };
var time = entry.Timestamp.ToString("HH:mm:ss"); var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
var cat = entry.ShortCategory.Length > 18 return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
? entry.ShortCategory[..18]
: entry.ShortCategory.PadRight(18);
var msg = entry.Message.Length > 80
? entry.Message[..80] + "..."
: entry.Message;
<Columns>
<Markup Content="@($"[grey]{time}[/]")" />
<Markup Content="@($" {levelTag} ")" Foreground="@levelColor" />
<Markup Content="@($"[grey]{cat}[/]")" />
<Markup Content="@($" {msg}")" />
</Columns>
} }
</Rows>
</ViewHeightScrollable>
}
</Rows>
@code { private static string Fit(string text, int width)
private string _selectedFilter = "All";
private int _scrollOffset = 0;
private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"];
private static readonly Dictionary<string, string?> FilterPrefixes = new()
{ {
["All"] = null, if (width <= 0)
["Jira"] = "LazyBear.MCP.Services.Jira",
["Kubernetes"] = "LazyBear.MCP.Services.Kubernetes",
["Confluence"] = "LazyBear.MCP.Services.Confluence",
["MCP"] = "ModelContextProtocol",
["System"] = "Microsoft"
};
private IReadOnlyList<LogEntry> GetFilteredEntries()
{ {
FilterPrefixes.TryGetValue(_selectedFilter, out var prefix); return string.Empty;
return LogSink.GetEntries(prefix);
} }
private void OnFilterChanged(string value) if (text.Length <= width)
{ {
_selectedFilter = value; return text.PadRight(width);
_scrollOffset = 0;
StateHasChanged();
} }
protected override void OnInitialized() if (width <= 3)
{ {
LogSink.OnLog += HandleNewLog; return text[..width];
} }
private void HandleNewLog(LogEntry _) return text[..(width - 3)] + "...";
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
LogSink.OnLog -= HandleNewLog;
} }
} }

View File

@@ -1,39 +1,80 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows> <Rows>
<Markup Content="Module Overview" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="Up/Down select a module. Enter opens its settings." Foreground="@UiPalette.TextMuted" />
<Markup Content=" " /> <Markup Content=" " />
@foreach (var module in Registry.GetModules())
@if (Rows.Count == 0)
{ {
var (active, total) = Registry.GetToolCounts(module.ModuleName); <Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
var isEnabled = Registry.IsModuleEnabled(module.ModuleName); <Markup Content="No modules registered." Foreground="@UiPalette.TextDim" />
var statusColor = isEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red; </Border>
var statusText = isEnabled ? "ENABLED" : "DISABLED";
var activeColor = active == total
? Spectre.Console.Color.Green
: (active == 0 ? Spectre.Console.Color.Red : Spectre.Console.Color.Yellow);
<Panel Title="@module.ModuleName"
BorderColor="@(isEnabled ? Spectre.Console.Color.Green3 : Spectre.Console.Color.Grey46)"
Expand="true">
<Columns>
<Rows>
<Columns>
<Markup Content="Status: " />
<Markup Content="@statusText" Foreground="@statusColor" />
</Columns>
<Columns>
<Markup Content="Tools: " />
<Markup Content="@($"{active}/{total} active")" Foreground="@activeColor" />
</Columns>
<Markup Content="@module.Description" Foreground="@Spectre.Console.Color.Grey" />
</Rows>
</Columns>
</Panel>
} }
else
{
<Select TItem="int"
Options="@GetOptions()"
Value="@GetNormalizedIndex()"
FocusedValue="@GetNormalizedIndex()"
Formatter="@FormatRow"
Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " /> <Markup Content=" " />
<Markup Content="[grey]Go to Settings tab to toggle modules and tools[/]" /> <Markup Content="@GetFooterText()" Foreground="@UiPalette.TextMuted" />
</Rows> </Rows>
@code { @code {
[Parameter, EditorRequired] public IReadOnlyList<OverviewRow> Rows { get; set; } = Array.Empty<OverviewRow>();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 3;
private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray();
private int GetNormalizedIndex() => Rows.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Rows.Count - 1);
private string GetFooterText()
{
if (Rows.Count == 0)
{
return "No integration modules available.";
}
var selected = Rows[Math.Clamp(SelectedIndex, 0, Rows.Count - 1)];
var state = selected.IsModuleEnabled ? "ON" : "OFF";
return $"{selected.ModuleName}: {selected.Description} | Module {state} | Tools {selected.ConfiguredTools}/{selected.TotalTools}";
}
private static Spectre.Console.Color GetRowForeground(OverviewRow row) =>
row.IsModuleEnabled ? UiPalette.Text : UiPalette.TextMuted;
private string FormatRow(int index)
{
var row = Rows[index];
var status = row.IsModuleEnabled ? "[ON] " : "[OFF]";
var text = $"{row.ModuleName,-12} {status} {row.ConfiguredTools,2}/{row.TotalTools,-2} {row.Description}";
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
}
private static string Fit(string text, int width)
{
if (width <= 0)
{
return string.Empty;
}
if (text.Length <= width)
{
return text.PadRight(width);
}
if (width <= 3)
{
return text[..width];
}
return text[..(width - 3)] + "...";
}
} }

View File

@@ -1,42 +1,89 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows> <Rows>
<Markup Content=" " /> <Markup Content="Tool Registry" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="[bold]Tool Registry — runtime enable/disable[/]" /> <Markup Content="Up/Down select. Left/Right collapse or expand. Space toggles state." Foreground="@UiPalette.TextMuted" />
<Markup Content="[grey]Changes take effect immediately without restart[/]" />
<Markup Content=" " /> <Markup Content=" " />
@{ @if (Entries.Count == 0)
int focusIdx = 20;
}
@foreach (var module in Registry.GetModules())
{ {
var moduleEnabled = Registry.IsModuleEnabled(module.ModuleName); <Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
var moduleColor = moduleEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red; <Markup Content="No modules available." Foreground="@UiPalette.TextDim" />
var moduleName = module.ModuleName; </Border>
var capturedFocus = focusIdx++;
<Panel Title="@module.ModuleName" BorderColor="@moduleColor" Expand="true">
<Rows>
<Columns>
<TextButton Content="@(moduleEnabled ? "[green]■ MODULE ENABLED[/]" : "[red]□ MODULE DISABLED[/]")"
OnClick="@(() => Registry.ToggleModule(moduleName))"
BackgroundColor="@(moduleEnabled ? Spectre.Console.Color.DarkGreen : Spectre.Console.Color.DarkRed)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@capturedFocus" />
<Markup Content="@($" {module.Description}")" Foreground="@Spectre.Console.Color.Grey" />
</Columns>
<Markup Content=" " />
<ToolButtonList Module="@module" StartFocusIdx="@focusIdx" />
</Rows>
</Panel>
focusIdx += module.ToolNames.Count;
<Markup Content=" " />
} }
else
{
<Select TItem="int"
Options="@GetOptions()"
Value="@GetNormalizedIndex()"
FocusedValue="@GetNormalizedIndex()"
Formatter="@FormatEntry"
Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " />
<Markup Content="@GetSelectedDescription()" Foreground="@UiPalette.TextMuted" />
</Rows> </Rows>
@code { @code {
[Parameter, EditorRequired] public IReadOnlyList<SettingsEntry> Entries { get; set; } = Array.Empty<SettingsEntry>();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 5;
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
private string GetSelectedDescription()
{
if (Entries.Count == 0)
{
return "Runtime enable/disable settings are unavailable.";
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
return selected.Description;
}
private string FormatEntry(int index)
{
var entry = Entries[index];
var indent = new string(' ', entry.Depth * 4);
var checkbox = entry.IsChecked ? "[x]" : "[ ]";
var disabledSuffix = entry.Kind == SettingsEntryKind.Tool && !entry.IsModuleEnabled ? " (module off)" : string.Empty;
string text;
if (entry.Kind == SettingsEntryKind.Module)
{
var expander = entry.IsExpanded ? "[-]" : "[+]";
text = $"{expander} {checkbox} {entry.Label}";
}
else
{
text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}";
}
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
}
private static string Fit(string text, int width)
{
if (width <= 0)
{
return string.Empty;
}
if (text.Length <= width)
{
return text.PadRight(width);
}
if (width <= 3)
{
return text[..width];
}
return text[..(width - 3)] + "...";
}
} }

View File

@@ -8,9 +8,9 @@
var moduleName = Module.ModuleName; var moduleName = Module.ModuleName;
var fo = StartFocusIdx + idx; var fo = StartFocusIdx + idx;
<TextButton Content="@(toolEnabled ? $"[green]✓[/] {toolName}" : $"[grey]✗[/] {toolName}")" <TextButton Content="@(toolEnabled ? $" {toolName}" : $" {toolName}")"
OnClick="@(() => Registry.ToggleTool(moduleName, toolName))" OnClick="@(() => Registry.ToggleTool(moduleName, toolName))"
BackgroundColor="@(toolEnabled ? Spectre.Console.Color.Grey19 : Spectre.Console.Color.Grey7)" BackgroundColor="@(toolEnabled ? Spectre.Console.Color.DarkGreen : Spectre.Console.Color.Grey11)"
FocusedColor="@Spectre.Console.Color.Yellow" FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@fo" /> FocusOrder="@fo" />
} }

View File

@@ -1,5 +1,8 @@
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using RazorConsole.Components @using RazorConsole.Components
@using RazorConsole.Core @using RazorConsole.Core
@using RazorConsole.Core.Rendering @using RazorConsole.Core.Rendering
@using LazyBear.MCP.TUI
@using LazyBear.MCP.TUI.Models
@using LazyBear.MCP.TUI.Components @using LazyBear.MCP.TUI.Components

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Hosting;
namespace LazyBear.MCP.TUI;
/// <summary>
/// Фоновый сервис для глобального чтения клавиш консоли.
/// Единственный источник клавишных событий для всего TUI.
///
/// Использует выделенный поток с блокирующим Console.ReadKey — никакого
/// polling-а, нет обращений к Console.KeyAvailable, которые захватывают
/// консольный mutex и мешают рендерингу RazorConsole.
/// </summary>
public sealed class GlobalKeyboardService : IHostedService, IDisposable
{
public event Action<ConsoleKeyInfo>? OnKeyPressed;
private Thread? _thread;
private volatile bool _stopping;
public Task StartAsync(CancellationToken cancellationToken)
{
if (Console.IsInputRedirected)
{
return Task.CompletedTask;
}
_thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "KeyboardReader"
};
_thread.Start();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_stopping = true;
// Console.ReadKey не поддерживает CancellationToken — поток
// завершится сам при выходе приложения (IsBackground = true).
return Task.CompletedTask;
}
private void ReadLoop()
{
while (!_stopping)
{
try
{
// Блокирующий вызов: не нагружает CPU и не трогает консольный
// mutex в паузах между нажатиями — рендеринг не страдает.
var key = Console.ReadKey(intercept: true);
if (!_stopping)
{
OnKeyPressed?.Invoke(key);
}
}
catch (InvalidOperationException)
{
// stdin стал недоступен (перенаправление и т.п.)
break;
}
catch
{
Thread.Sleep(100);
}
}
}
public void Dispose()
{
_stopping = true;
}
}

View File

@@ -0,0 +1,8 @@
namespace LazyBear.MCP.TUI.Models;
public sealed record OverviewRow(
string ModuleName,
string Description,
bool IsModuleEnabled,
int ConfiguredTools,
int TotalTools);

View File

@@ -0,0 +1,18 @@
namespace LazyBear.MCP.TUI.Models;
public enum SettingsEntryKind
{
Module,
Tool
}
public sealed record SettingsEntry(
SettingsEntryKind Kind,
string ModuleName,
string? ToolName,
string Label,
string Description,
bool IsChecked,
bool IsModuleEnabled,
bool IsExpanded,
int Depth);

View File

@@ -1,69 +0,0 @@
using LazyBear.MCP.Services.ToolRegistry;
using LazyBear.MCP.TUI.Components;
using Microsoft.Extensions.Hosting;
using RazorConsole.Core;
namespace LazyBear.MCP.TUI;
/// <summary>
/// Запускает RazorConsole TUI как IHostedService в отдельном потоке,
/// чтобы не блокировать ASP.NET Core pipeline.
/// </summary>
public sealed class TuiHostedService(IServiceProvider services, ILogger<TuiHostedService> logger) : IHostedService
{
private Thread? _tuiThread;
private CancellationTokenSource? _cts;
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
// Регистрируем все IToolModule-модули в ToolRegistryService
var registry = services.GetRequiredService<ToolRegistryService>();
foreach (var module in services.GetServices<IToolModule>())
{
registry.RegisterModule(module);
}
_tuiThread = new Thread(RunTui)
{
IsBackground = true,
Name = "RazorConsole-TUI"
};
_tuiThread.Start();
logger.LogInformation("TUI запущен в фоновом потоке");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
logger.LogInformation("TUI остановлен");
return Task.CompletedTask;
}
private void RunTui()
{
try
{
var host = Host.CreateDefaultBuilder()
.UseRazorConsole<App>(configure: configure =>
{
configure.ConfigureServices((_, svc) =>
{
// Пробрасываем ключевые Singleton из основного DI-контейнера в TUI-контейнер
svc.AddSingleton(services.GetRequiredService<ToolRegistryService>());
svc.AddSingleton(services.GetRequiredService<Services.Logging.InMemoryLogSink>());
});
})
.Build();
host.Run();
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка в потоке TUI");
}
}
}

View File

@@ -0,0 +1,21 @@
using Spectre.Console;
namespace LazyBear.MCP.TUI;
internal static class UiPalette
{
public static readonly Color Frame = new(26, 44, 64);
public static readonly Color Surface = new(12, 21, 34);
public static readonly Color SurfaceAlt = new(18, 29, 44);
public static readonly Color SurfaceMuted = new(28, 40, 56);
public static readonly Color Accent = Color.Cyan1;
public static readonly Color AccentSoft = Color.DeepSkyBlue1;
public static readonly Color Text = Color.Grey93;
public static readonly Color TextMuted = Color.Grey62;
public static readonly Color TextDim = Color.Grey46;
public static readonly Color Success = Color.Green3;
public static readonly Color Warning = Color.Yellow3;
public static readonly Color Danger = Color.Red3;
public static readonly Color SelectionBackground = new(24, 152, 181);
public static readonly Color SelectionForeground = new(7, 18, 31);
}

192
docs/tui_log.md Normal file
View File

@@ -0,0 +1,192 @@
# TUI Keyboard Handling — разбор и решение
## Проблема
После внедрения RazorConsole TUI не работали два базовых взаимодействия:
- **Tab** не переключал вкладки (Overview / Logs / Settings)
- **Стрелки** не навигировали по спискам
## Диагностика
### Что проверяли
Изучили исходную архитектуру: каждый дочерний компонент (`OverviewTab`, `LogsTab`, `SettingsTab`) рендерил `<Select TItem="int">` из `RazorConsole.Components` с атрибутом `@onkeydown="OnKeyDown"`. `OnKeyDown` — это `EventCallback<KeyboardEventArgs>`, пробрасываемый из `App.razor`.
### Корневая причина — RazorConsole 0.5.0
Изучили пакет `RazorConsole.Core 0.5.0` (XML-документация, структура NuGet):
1. **`@onkeydown` на `<Select>` не работает для служебных клавиш.**
`Select` получает `@onkeydown` как часть `AdditionalAttributes`. Стрелки и Enter компонент обрабатывает внутренне (переключает `FocusedValue`), наружу через callback они не выходят.
2. **Tab перехватывается FocusManager до `<Select>`.**
Внутренняя инфраструктура RazorConsole вызывает `FocusManager.FocusNextAsync()` при нажатии Tab ещё до того, как событие доходит до компонента. Наш хендлер в `App.razor` не вызывался вообще.
3. **Глобального перехвата клавиш в 0.5.0 нет.**
`ConsoleAppOptions` содержит только три поля (`AutoClearConsole`, `EnableTerminalResizing`, `AfterRenderAsync`) — никаких `OnKeyPress` / `IKeyboardHandler`. Публичного API для регистрации глобального обработчика не предусмотрено.
4. **`Select.FocusedValue` vs `Select.Value`.**
`Value` / `ValueChanged` — «зафиксированный» выбор, обновляется только при подтверждении (Enter). `FocusedValue` / `FocusedValueChanged` — подсветка при навигации стрелками. Мы подписывались на `ValueChanged`, поэтому даже если стрелки что-то делали внутри Select — наш стейт не обновлялся.
## Решение
### GlobalKeyboardService
Создали `LazyBear.MCP/TUI/GlobalKeyboardService.cs` — единственный источник клавишных событий для всего TUI. Реализует `IHostedService` (не `BackgroundService`), запускает **выделенный фоновый поток** с блокирующим `Console.ReadKey(intercept: true)`.
Зарегистрирован как `Singleton + IHostedService` в `Program.cs`, чтобы можно было инжектить в компоненты:
```csharp
services.AddSingleton<GlobalKeyboardService>();
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
```
### App.razor
- Убраны `FocusManager` и `OnAfterRenderAsync` (FocusManager мешал: перехватывал Tab для собственного `FocusNextAsync`).
- Подписка на `KeyboardService.OnKeyPressed` в `OnInitialized`, отписка в `Dispose`.
- `ConvertKey(ConsoleKeyInfo)` конвертирует нажатие в `KeyboardEventArgs` с именами в стиле браузера (`"ArrowUp"`, `"Tab"`, `" "` и т.д.).
- Вся логика обработки (`HandleOverviewKey`, `HandleLogsKey`, `HandleSettingsKey`) осталась без изменений — переехала из `async Task` в синхронный `void`, `StateHasChanged()` вызывается один раз в конце через `InvokeAsync`.
### Дочерние компоненты
Из `OverviewTab`, `LogsTab`, `SettingsTab`:
- Удалён параметр `OnKeyDown`
- Удалён `@onkeydown="OnKeyDown"` с `<Select>`
- Добавлен `FocusedValue="@GetNormalizedIndex()"` — чтобы индикатор `>` отображался рядом с текущим элементом, управляемым извне
## Проблема производительности (тормоза)
Первая реализация `GlobalKeyboardService` использовала polling:
```csharp
// ❌ Было — 100 вызовов/сек Console.KeyAvailable
while (!stoppingToken.IsCancellationRequested)
{
if (Console.KeyAvailable)
OnKeyPressed?.Invoke(Console.ReadKey(intercept: true));
else
await Task.Delay(10, stoppingToken);
}
```
`Console.KeyAvailable` под капотом захватывает консольный mutex каждые 10ms. RazorConsole для рендеринга тоже обращается к консоли — возникала конкуренция, UI тормозил.
**Исправление** — блокирующий поток без polling:
```csharp
// ✓ Стало — выделенный поток, спит на уровне ОС между нажатиями
_thread = new Thread(ReadLoop) { IsBackground = true, Name = "KeyboardReader" };
void ReadLoop() {
while (!_stopping)
OnKeyPressed?.Invoke(Console.ReadKey(intercept: true)); // блокирует без mutex-а
}
```
Между нажатиями поток блокируется на уровне ОС, консольный mutex свободен, рендеринг не конкурирует ни за какой ресурс.
## Итоговые изменения
| Файл | Что сделано |
|------|-------------|
| `TUI/GlobalKeyboardService.cs` | Создан. Блокирующий ReadKey в выделенном потоке |
| `Program.cs` | Регистрация `GlobalKeyboardService` как singleton + hosted service |
| `TUI/Components/App.razor` | Убран FocusManager. Подписка на GlobalKeyboardService. Sync key handler |
| `TUI/Components/OverviewTab.razor` | Убран OnKeyDown. Добавлен FocusedValue |
| `TUI/Components/LogsTab.razor` | Убран OnKeyDown. Добавлен FocusedValue |
| `TUI/Components/SettingsTab.razor` | Убран OnKeyDown. Добавлен FocusedValue |
| `TUI/Components/_Imports.razor` | Убран `@using RazorConsole.Core.Focus` |
| `InspectRazorConsole/` | Временный проект для инспекции DLL (можно удалить) |
## Вывод по RazorConsole 0.5.0
Фреймворк хорошо подходит для рендеринга (Spectre.Console под капотом), но клавишные события — слабое место. `@onkeydown` на интерактивных компонентах не даёт полного контроля: служебные клавиши перехватываются внутри. Для любого нетривиального TUI с собственной навигацией нужен независимый читатель консольного ввода — либо свой поток с `Console.ReadKey`, либо (если появится) публичный API глобального перехвата в будущих версиях фреймворка.
---
## Ключевые моменты при работе с RazorConsole (0.5.0)
Справочник для написания кода и диагностики багов.
### Архитектура и рендеринг
- **Рендеринг — Spectre.Console под капотом.** Компоненты транслируются через VDOM-слой в `IRenderable` объекты Spectre. Всё, что умеет Spectre.Console (цвета, границы, таблицы, разметка), доступно через Razor-компоненты.
- **`AutoClearConsole = true` в `ConsoleAppOptions`** — обязательно, иначе при перерисовке вывод накапливается и экран «растёт». Устанавливается через `services.Configure<ConsoleAppOptions>(...)` внутри `UseRazorConsole`.
- **`EnableTerminalResizing = true`** — без этого изменение размера окна не триггерит перерисовку. Нужно для корректного поведения `Console.WindowHeight` в вычислениях viewport.
- **`AfterRenderAsync`** — хук после каждого рендера. Единственное место, где гарантированно можно записать в stdout после того, как RazorConsole завершил свой вывод. Используется, например, чтобы скрыть курсор (`Console.CursorVisible = false` + ANSI `\e[?25l`).
- **`StateHasChanged()` нужно вызывать через `InvokeAsync`** при вызове из фонового потока или подписчика события. Иначе — исключение о вызове не из диспетчера компонента:
```csharp
// ✓ из любого потока
InvokeAsync(() => { /* изменить стейт */ StateHasChanged(); });
```
- **Частые `StateHasChanged` → видимые тормоза.** Каждый вызов — полная перерисовка терминала (очистка + вывод). При высокочастотных событиях (потоковые логи, таймеры) нужна дебаунс-логика или throttle: планировать один рендер на «пакет» событий, а не по одному на каждое.
### Компоненты
#### `<Select TItem="T">`
- **`Value` / `ValueChanged`** — зафиксированный выбор. Обновляется только когда пользователь «подтверждает» (Enter). Не подходит для отслеживания позиции курсора в реальном времени.
- **`FocusedValue` / `FocusedValueChanged`** — текущая подсвеченная строка при навигации стрелками. Нужно использовать именно этот параметр для синхронизации позиции курсора с внешним стейтом.
- **`SelectedIndicator`** — символ рядом с текущей `FocusedValue`. Без явной установки `FocusedValue` индикатор может не отображаться или «застывать» на первом элементе.
- **`@onkeydown` на `<Select>` не работает для служебных клавиш** (Tab, Arrow, Enter, Space). Эти клавиши потребляются компонентом или FocusManager до того, как попадают в callback. Не использовать `@onkeydown` на `<Select>` для логики навигации.
- **Прокрутка:** `Select` не принимает параметр `ViewportRows` явно — он сам управляет отображением. Для явного ограничения viewport нужен `<Scrollable<T>>` или внешнее ограничение списка `Options`.
#### `<Scrollable<T>>`
- Управляет пагинацией и клавишной навигацией (стрелки, PageUp/Down, Home/End) самостоятельно.
- `ChildContent` получает `ScrollContext<T>` с видимыми элементами и (по документации) keyboard event handlers. Конкретные названия handler-ов в XML-документации не описаны — нужна инспекция DLL или исходники.
- `PageSize` — количество видимых элементов за раз (по умолчанию 1).
#### `<TextInput>`
- `OnInput` — срабатывает на **каждый** символ. Подходит для захвата произвольных нажатий если нужен «ввод текста».
- `OnSubmit` — Enter.
- Не использовать как скрытый «перехватчик» клавиш: он не перехватывает служебные клавиши (Tab, стрелки).
#### `<TextButton>`
- `OnClick` — активируется Enter или пробелом когда кнопка в фокусе.
- Нет события `OnFocus` / `IsFocusedChanged` — нельзя реагировать на момент получения фокуса через Tab.
### FocusManager
- **Не использовать `FocusNextAsync()` в `OnAfterRenderAsync` без условия** — вызов после каждого рендера перемещает фокус по кругу, сбивая пользовательскую навигацию.
- **Tab глобально обрабатывается FocusManager** — `FocusNextAsync()` вызывается фреймворком автоматически. Повторный вызов в коде → двойное смещение фокуса.
- **`FocusManager.CurrentFocusKey`** — ключ текущего элемента в фокусе. Можно использовать для восстановления фокуса: сохранить ключ, после рендера вызвать `FocusAsync(savedKey)`.
- **Если в интерфейсе нет ни одного фокусируемого компонента** (`Select`, `TextInput`, `TextButton`) — FocusManager не читает клавиши. Это позволяет безопасно взять чтение консоли на себя.
### Ввод с клавиатуры (правила)
1. **Один читатель консоли.** `Console.ReadKey` не мультиплексируется — если FocusManager читает клавиши (есть хотя бы один фокусируемый компонент), свой `ReadKey`-поток конкурирует с ним и теряет нажатия.
2. **Забрать ввод целиком** можно убрав все фокусируемые компоненты и запустив собственный поток с `Console.ReadKey(intercept: true)`. Тогда FocusManager не активирует свой reader.
3. **Polling `Console.KeyAvailable` — антипаттерн.** Под Windows захватывает консольный mutex при каждой проверке. При частом опросе (< 50ms) конкурирует с рендером и вызывает заметные тормоза. Используй блокирующий `Console.ReadKey` в выделенном потоке.
4. **Нажатие из фонового потока → `InvokeAsync`.** После чтения клавиши и изменения стейта компонента всегда оборачивай в `InvokeAsync` перед `StateHasChanged`.
### Регистрация в DI
```csharp
// Паттерн: сервис доступен и как синглтон для инжекции, и как IHostedService для запуска
services.AddSingleton<MyService>();
services.AddHostedService(sp => sp.GetRequiredService<MyService>());
```
Альтернатива `AddHostedService<T>()` регистрирует **transient**-экземпляр, который нельзя инжектить — для TUI-сервисов всегда использовать паттерн выше.
### Отладка
- **Логи не видны в терминале во время работы TUI** — RazorConsole владеет stdout. Все `.NET`-логи надо направлять в `InMemoryLogSink` и отображать во вкладке Logs.
- **Для инспекции публичного API пакета** без исходников: создать временный `net10.0`-проект, загрузить DLL через `Assembly.LoadFrom` и перечислить типы/методы рефлексией. XML-документация пакета (`.xml` рядом с `.dll` в NuGet-кеше) — первая точка поиска.
- **`Console.IsInputRedirected`** — проверять перед любым обращением к `Console.ReadKey` / `Console.KeyAvailable`. В CI или при перенаправлении stdin эти вызовы бросают исключение.