From 9b27cd7dc2ea5291992518c2cf6da6c52059db11 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Mon, 13 Apr 2026 23:31:53 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B2=20TUI=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?GlobalKeyboardService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавить GlobalKeyboardService — выделенный поток с блокирующим Console.ReadKey, единственный источник клавишных событий для TUI - Убрать FocusManager из App.razor: перехватывал Tab до компонентов - Удалить @onkeydown с ` 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 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 - **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 +- **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()` + `services.AddHostedService(sp => sp.GetRequiredService())` +- **`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 diff --git a/LazyBear.MCP/LazyBear.MCP.csproj b/LazyBear.MCP/LazyBear.MCP.csproj index 6232bce..85acfe1 100644 --- a/LazyBear.MCP/LazyBear.MCP.csproj +++ b/LazyBear.MCP/LazyBear.MCP.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + false false diff --git a/LazyBear.MCP/Program.cs b/LazyBear.MCP/Program.cs index eb4849e..54c6eaf 100644 --- a/LazyBear.MCP/Program.cs +++ b/LazyBear.MCP/Program.cs @@ -2,40 +2,87 @@ using LazyBear.MCP.Services.Confluence; using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Kubernetes; using LazyBear.MCP.Services.Logging; +using LazyBear.MCP.Services.Mcp; using LazyBear.MCP.Services.ToolRegistry; 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); - -// ── InMemoryLogSink: регистрируем Singleton и кастомный логгер ─────────────── +// ── Общий логгер и один DI-контейнер для TUI + MCP ────────────────────────── var logSink = new InMemoryLogSink(); -builder.Services.AddSingleton(logSink); -builder.Logging.AddProvider(new InMemoryLoggerProvider(logSink)); -// ── MCP-провайдеры ─────────────────────────────────────────────────────────── -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddSingleton(logSink); + services.AddSingleton(); -// ── ToolRegistry ───────────────────────────────────────────────────────────── -builder.Services.AddSingleton(); + // MCP-провайдеры + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); -// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); + // Модули инструментов (добавь новый IToolModule — он появится в TUI) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); -// ── MCP-сервер ─────────────────────────────────────────────────────────────── -builder.Services.AddMcpServer() - .WithHttpTransport() - .WithToolsFromAssembly(); + // HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли + services.AddHostedService(); -// ── TUI как фоновый сервис ─────────────────────────────────────────────────── -builder.Services.AddHostedService(); + // Глобальный читатель клавиш — единственный источник клавишных событий для TUI + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + }) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddProvider(new InMemoryLoggerProvider(logSink)); + }) + .UseRazorConsole(hostBuilder => + { + hostBuilder.ConfigureServices(services => + { + services.Configure(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"; -app.Run(urls); +// ── Регистрируем модули один раз до старта TUI и web host ─────────────────── +var registry = host.Services.GetRequiredService(); +foreach (var module in host.Services.GetServices()) +{ + registry.RegisterModule(module); +} + +await host.RunAsync(); diff --git a/LazyBear.MCP/Services/Mcp/McpWebHostedService.cs b/LazyBear.MCP/Services/Mcp/McpWebHostedService.cs new file mode 100644 index 0000000..ec4ff87 --- /dev/null +++ b/LazyBear.MCP/Services/Mcp/McpWebHostedService.cs @@ -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; + +/// +/// Поднимает HTTP MCP endpoint в фоне, не вмешиваясь в основной TUI event loop. +/// Использует общие singleton-экземпляры из root host. +/// +public sealed class McpWebHostedService( + IServiceProvider rootServices, + IConfiguration configuration, + ILogger 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()); + builder.Services.AddSingleton(rootServices.GetRequiredService()); + builder.Services.AddSingleton(rootServices.GetRequiredService()); + builder.Services.AddSingleton(rootServices.GetRequiredService()); + builder.Services.AddSingleton(rootServices.GetRequiredService()); + + foreach (var module in rootServices.GetServices()) + { + 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; + } +} diff --git a/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs b/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs index 49eafb3..9dfc435 100644 --- a/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs +++ b/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs @@ -49,9 +49,12 @@ public sealed class ToolRegistryService public bool IsModuleEnabled(string moduleName) => _moduleEnabled.GetValueOrDefault(moduleName, true); + public bool IsToolConfiguredEnabled(string moduleName, string toolName) => + _toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true); + public bool IsToolEnabled(string moduleName, string toolName) => IsModuleEnabled(moduleName) && - _toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true); + IsToolConfiguredEnabled(moduleName, toolName); // ── Переключение ───────────────────────────────────────────────────────── @@ -71,7 +74,7 @@ public sealed class ToolRegistryService SetModuleEnabled(moduleName, !IsModuleEnabled(moduleName)); public void ToggleTool(string moduleName, string toolName) => - SetToolEnabled(moduleName, toolName, !IsToolEnabled(moduleName, toolName)); + SetToolEnabled(moduleName, toolName, !IsToolConfiguredEnabled(moduleName, toolName)); // ── Счётчики для 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 ─────────────────────────────────────────────────────────────── private static string MakeKey(string module, string tool) => $"{module}::{tool}"; diff --git a/LazyBear.MCP/TUI/Components/App.razor b/LazyBear.MCP/TUI/Components/App.razor index de24501..6aeed23 100644 --- a/LazyBear.MCP/TUI/Components/App.razor +++ b/LazyBear.MCP/TUI/Components/App.razor @@ -2,80 +2,542 @@ @using LazyBear.MCP.Services.ToolRegistry @inject ToolRegistryService Registry @inject InMemoryLogSink LogSink +@inject GlobalKeyboardService KeyboardService @implements IDisposable - - - - @* Таб-навигация *@ + + + + + + - - - + @foreach (var tab in _tabs) + { + var isActive = _activeTab == tab; + + + } - @* Контент таба *@ + + @if (_activeTab == Tab.Overview) { - + } else if (_activeTab == Tab.Logs) { - + } else { - + } @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 _expandedModules = new(StringComparer.Ordinal); + 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() { - Registry.StateChanged += OnStateChanged; + Registry.StateChanged += OnRegistryChanged; LogSink.OnLog += OnNewLog; + KeyboardService.OnKeyPressed += OnConsoleKeyPressed; } - private void SetTab(Tab tab) + // Конвертация ConsoleKeyInfo → KeyboardEventArgs для переиспользования существующей логики + private static KeyboardEventArgs ConvertKey(ConsoleKeyInfo key) { - _activeTab = tab; - StateHasChanged(); - } - - private void OnStateChanged() - { - InvokeAsync(StateHasChanged); - } - - private void OnNewLog(LogEntry _) - { - if (_activeTab == Tab.Logs) + var name = key.Key switch { - InvokeAsync(StateHasChanged); + 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(); + }); + } + + private void HandleKeyDown(KeyboardEventArgs args) + { + if (string.Equals(args.Key, "Tab", StringComparison.Ordinal)) + { + ChangeTab(args.ShiftKey ? -1 : 1); + return; + } + + switch (_activeTab) + { + case Tab.Overview: + HandleOverviewKey(args); + 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 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 GetSettingsEntries() + { + var entries = new List(); + + 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 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() { - Registry.StateChanged -= OnStateChanged; + Registry.StateChanged -= OnRegistryChanged; LogSink.OnLog -= OnNewLog; + KeyboardService.OnKeyPressed -= OnConsoleKeyPressed; } } diff --git a/LazyBear.MCP/TUI/Components/LogsTab.razor b/LazyBear.MCP/TUI/Components/LogsTab.razor index 5cc009d..7690931 100644 --- a/LazyBear.MCP/TUI/Components/LogsTab.razor +++ b/LazyBear.MCP/TUI/Components/LogsTab.razor @@ -1,119 +1,131 @@ @using LazyBear.MCP.Services.Logging -@inject InMemoryLogSink LogSink - -@implements IDisposable + + - @* Фильтр по модулю *@ - - } + + + + @code { - private string _selectedFilter = "All"; - private int _scrollOffset = 0; + private static readonly string[] Filters = ["All", "Info", "Warn", "Error"]; - private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"]; + [Parameter, EditorRequired] public IReadOnlyList Entries { get; set; } = Array.Empty(); + [Parameter] public int SelectedIndex { get; set; } + [Parameter] public EventCallback SelectedIndexChanged { get; set; } + [Parameter] public string SelectedFilter { get; set; } = "All"; + [Parameter] public int ViewportRows { get; set; } = 5; + [Parameter] public bool IsStickyToBottom { get; set; } - private static readonly Dictionary FilterPrefixes = new() + 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() { - ["All"] = null, - ["Jira"] = "LazyBear.MCP.Services.Jira", - ["Kubernetes"] = "LazyBear.MCP.Services.Kubernetes", - ["Confluence"] = "LazyBear.MCP.Services.Confluence", - ["MCP"] = "ModelContextProtocol", - ["System"] = "Microsoft" + if (Entries.Count == 0) + { + return $"Filter: {SelectedFilter}"; + } + + 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 }; - private IReadOnlyList GetFilteredEntries() + private string FormatEntry(int index) { - FilterPrefixes.TryGetValue(_selectedFilter, out var prefix); - return LogSink.GetEntries(prefix); + var entry = Entries[index]; + var level = entry.Level switch + { + LogLevel.Error => "ERR", + LogLevel.Critical => "CRT", + LogLevel.Warning => "WRN", + LogLevel.Information => "INF", + LogLevel.Debug => "DBG", + _ => "TRC" + }; + + var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}"; + return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); } - private void OnFilterChanged(string value) + private static string Fit(string text, int width) { - _selectedFilter = value; - _scrollOffset = 0; - StateHasChanged(); - } + if (width <= 0) + { + return string.Empty; + } - protected override void OnInitialized() - { - LogSink.OnLog += HandleNewLog; - } + if (text.Length <= width) + { + return text.PadRight(width); + } - private void HandleNewLog(LogEntry _) - { - InvokeAsync(StateHasChanged); - } + if (width <= 3) + { + return text[..width]; + } - public void Dispose() - { - LogSink.OnLog -= HandleNewLog; + return text[..(width - 3)] + "..."; } } diff --git a/LazyBear.MCP/TUI/Components/OverviewTab.razor b/LazyBear.MCP/TUI/Components/OverviewTab.razor index d1f44b2..f692cb3 100644 --- a/LazyBear.MCP/TUI/Components/OverviewTab.razor +++ b/LazyBear.MCP/TUI/Components/OverviewTab.razor @@ -1,39 +1,80 @@ -@using LazyBear.MCP.Services.ToolRegistry -@inject ToolRegistryService Registry - + + - @foreach (var module in Registry.GetModules()) - { - var (active, total) = Registry.GetToolCounts(module.ModuleName); - var isEnabled = Registry.IsModuleEnabled(module.ModuleName); - var statusColor = isEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red; - var statusText = isEnabled ? "ENABLED" : "DISABLED"; - var activeColor = active == total - ? Spectre.Console.Color.Green - : (active == 0 ? Spectre.Console.Color.Red : Spectre.Console.Color.Yellow); - - - - - - - - - - - - - - - + @if (Rows.Count == 0) + { + + + } + else + { + + } + + + @code { + [Parameter, EditorRequired] public IReadOnlyList Entries { get; set; } = Array.Empty(); + [Parameter] public int SelectedIndex { get; set; } + [Parameter] public EventCallback 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)] + "..."; + } } diff --git a/LazyBear.MCP/TUI/Components/ToolButtonList.razor b/LazyBear.MCP/TUI/Components/ToolButtonList.razor index 38b47cb..67f7e54 100644 --- a/LazyBear.MCP/TUI/Components/ToolButtonList.razor +++ b/LazyBear.MCP/TUI/Components/ToolButtonList.razor @@ -8,9 +8,9 @@ var moduleName = Module.ModuleName; var fo = StartFocusIdx + idx; - } diff --git a/LazyBear.MCP/TUI/Components/_Imports.razor b/LazyBear.MCP/TUI/Components/_Imports.razor index b86abbf..044f3f2 100644 --- a/LazyBear.MCP/TUI/Components/_Imports.razor +++ b/LazyBear.MCP/TUI/Components/_Imports.razor @@ -1,5 +1,8 @@ @using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web @using RazorConsole.Components @using RazorConsole.Core @using RazorConsole.Core.Rendering +@using LazyBear.MCP.TUI +@using LazyBear.MCP.TUI.Models @using LazyBear.MCP.TUI.Components diff --git a/LazyBear.MCP/TUI/GlobalKeyboardService.cs b/LazyBear.MCP/TUI/GlobalKeyboardService.cs new file mode 100644 index 0000000..2a00368 --- /dev/null +++ b/LazyBear.MCP/TUI/GlobalKeyboardService.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Hosting; + +namespace LazyBear.MCP.TUI; + +/// +/// Фоновый сервис для глобального чтения клавиш консоли. +/// Единственный источник клавишных событий для всего TUI. +/// +/// Использует выделенный поток с блокирующим Console.ReadKey — никакого +/// polling-а, нет обращений к Console.KeyAvailable, которые захватывают +/// консольный mutex и мешают рендерингу RazorConsole. +/// +public sealed class GlobalKeyboardService : IHostedService, IDisposable +{ + public event Action? 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; + } +} diff --git a/LazyBear.MCP/TUI/Models/OverviewRow.cs b/LazyBear.MCP/TUI/Models/OverviewRow.cs new file mode 100644 index 0000000..603c0bd --- /dev/null +++ b/LazyBear.MCP/TUI/Models/OverviewRow.cs @@ -0,0 +1,8 @@ +namespace LazyBear.MCP.TUI.Models; + +public sealed record OverviewRow( + string ModuleName, + string Description, + bool IsModuleEnabled, + int ConfiguredTools, + int TotalTools); diff --git a/LazyBear.MCP/TUI/Models/SettingsEntry.cs b/LazyBear.MCP/TUI/Models/SettingsEntry.cs new file mode 100644 index 0000000..0296d88 --- /dev/null +++ b/LazyBear.MCP/TUI/Models/SettingsEntry.cs @@ -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); diff --git a/LazyBear.MCP/TUI/TuiHostedService.cs b/LazyBear.MCP/TUI/TuiHostedService.cs deleted file mode 100644 index c624b4d..0000000 --- a/LazyBear.MCP/TUI/TuiHostedService.cs +++ /dev/null @@ -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; - -/// -/// Запускает RazorConsole TUI как IHostedService в отдельном потоке, -/// чтобы не блокировать ASP.NET Core pipeline. -/// -public sealed class TuiHostedService(IServiceProvider services, ILogger logger) : IHostedService -{ - private Thread? _tuiThread; - private CancellationTokenSource? _cts; - - public Task StartAsync(CancellationToken cancellationToken) - { - _cts = new CancellationTokenSource(); - - // Регистрируем все IToolModule-модули в ToolRegistryService - var registry = services.GetRequiredService(); - foreach (var module in services.GetServices()) - { - 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(configure: configure => - { - configure.ConfigureServices((_, svc) => - { - // Пробрасываем ключевые Singleton из основного DI-контейнера в TUI-контейнер - svc.AddSingleton(services.GetRequiredService()); - svc.AddSingleton(services.GetRequiredService()); - }); - }) - .Build(); - - host.Run(); - } - catch (Exception ex) - { - logger.LogError(ex, "Ошибка в потоке TUI"); - } - } -} diff --git a/LazyBear.MCP/TUI/UiPalette.cs b/LazyBear.MCP/TUI/UiPalette.cs new file mode 100644 index 0000000..6197042 --- /dev/null +++ b/LazyBear.MCP/TUI/UiPalette.cs @@ -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); +} diff --git a/docs/tui_log.md b/docs/tui_log.md new file mode 100644 index 0000000..3b37f51 --- /dev/null +++ b/docs/tui_log.md @@ -0,0 +1,192 @@ +# TUI Keyboard Handling — разбор и решение + +## Проблема + +После внедрения RazorConsole TUI не работали два базовых взаимодействия: +- **Tab** не переключал вкладки (Overview / Logs / Settings) +- **Стрелки** не навигировали по спискам + +## Диагностика + +### Что проверяли + +Изучили исходную архитектуру: каждый дочерний компонент (`OverviewTab`, `LogsTab`, `SettingsTab`) рендерил `` не работает для служебных клавиш.** + `Select` получает `@onkeydown` как часть `AdditionalAttributes`. Стрелки и Enter компонент обрабатывает внутренне (переключает `FocusedValue`), наружу через callback они не выходят. + +2. **Tab перехватывается FocusManager до `` +- Добавлен `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(...)` внутри `UseRazorConsole`. + +- **`EnableTerminalResizing = true`** — без этого изменение размера окна не триггерит перерисовку. Нужно для корректного поведения `Console.WindowHeight` в вычислениях viewport. + +- **`AfterRenderAsync`** — хук после каждого рендера. Единственное место, где гарантированно можно записать в stdout после того, как RazorConsole завершил свой вывод. Используется, например, чтобы скрыть курсор (`Console.CursorVisible = false` + ANSI `\e[?25l`). + +- **`StateHasChanged()` нужно вызывать через `InvokeAsync`** при вызове из фонового потока или подписчика события. Иначе — исключение о вызове не из диспетчера компонента: + ```csharp + // ✓ из любого потока + InvokeAsync(() => { /* изменить стейт */ StateHasChanged(); }); + ``` + +- **Частые `StateHasChanged` → видимые тормоза.** Каждый вызов — полная перерисовка терминала (очистка + вывод). При высокочастотных событиях (потоковые логи, таймеры) нужна дебаунс-логика или throttle: планировать один рендер на «пакет» событий, а не по одному на каждое. + +### Компоненты + +#### `` не работает для служебных клавиш** (Tab, Arrow, Enter, Space). Эти клавиши потребляются компонентом или FocusManager до того, как попадают в callback. Не использовать `@onkeydown` на `