From d12e9873f0964f2c275a634cda80b161c83f9bbb Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Tue, 14 Apr 2026 01:23:55 +0300 Subject: [PATCH] =?UTF-8?q?TUI:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20shell=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BF=D1=82=D0=B0=D1=86=D0=B8=D1=8E=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LazyBear.MCP/LazyBear.MCP.csproj | 3 + LazyBear.MCP/TUI/Components/App.razor | 219 ++++++++++++++++-- LazyBear.MCP/TUI/Components/LogsTab.razor | 5 +- LazyBear.MCP/TUI/Components/OverviewTab.razor | 6 +- LazyBear.MCP/TUI/Components/SettingsTab.razor | 3 +- LazyBear.MCP/TUI/Localization/TuiResources.cs | 31 ++- LazyBear.MCP/TUI/UiMetrics.cs | 43 ++++ LazyBear.MCP/TUI/UiPalette.cs | 2 + LazyBear.MCP/logo.svg | 77 ++++++ 9 files changed, 346 insertions(+), 43 deletions(-) create mode 100644 LazyBear.MCP/TUI/UiMetrics.cs create mode 100644 LazyBear.MCP/logo.svg diff --git a/LazyBear.MCP/LazyBear.MCP.csproj b/LazyBear.MCP/LazyBear.MCP.csproj index 85acfe1..ef93b17 100644 --- a/LazyBear.MCP/LazyBear.MCP.csproj +++ b/LazyBear.MCP/LazyBear.MCP.csproj @@ -18,6 +18,9 @@ + + LazyBear.MCP + diff --git a/LazyBear.MCP/TUI/Components/App.razor b/LazyBear.MCP/TUI/Components/App.razor index 44ed906..d463a88 100644 --- a/LazyBear.MCP/TUI/Components/App.razor +++ b/LazyBear.MCP/TUI/Components/App.razor @@ -9,37 +9,31 @@ @implements IDisposable - + + @foreach (var segment in BuildHeaderSegments()) + { + + } + + + - - - - - @foreach (var tab in _tabs) - { - var isActive = _activeTab == tab; - - - } - - - - @if (_activeTab == Tab.Overview) { } else if (_activeTab == Tab.Logs) @@ -62,9 +56,38 @@ } + + + + + + + + + + + + + + + @code { + private const char Nbsp = '\u00A0'; + private enum Tab { Overview, @@ -72,6 +95,12 @@ Settings } + private readonly record struct HeaderSegment( + string Content, + Spectre.Console.Color Foreground, + Spectre.Console.Color Background, + Spectre.Console.Decoration Decoration); + 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); @@ -82,14 +111,16 @@ private int _logSelection; private int _settingsSelection; private bool _logsStickToBottom = true; + private bool _isHelpOpen; private static readonly string _mcpEndpoint = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000"; - 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); + private static int GetPanelHeight() => Math.Max(UiMetrics.ConsoleHeight - 4, 10); + private static int GetOverviewViewportRows() => Math.Max(GetPanelHeight() - 6, 3); + private static int GetLogsViewportRows() => Math.Max(GetPanelHeight() - 10, 5); + private static int GetSettingsViewportRows() => Math.Max(GetPanelHeight() - 7, 5); + private static int GetHelpModalWidth() => Math.Max(Math.Min(UiMetrics.ConsoleWidth - 6, 84), 36); protected override void OnInitialized() { @@ -115,6 +146,7 @@ ConsoleKey.PageUp => "PageUp", ConsoleKey.PageDown => "PageDown", ConsoleKey.Tab => "Tab", + ConsoleKey.Escape => "Escape", _ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString() }; @@ -145,6 +177,22 @@ private void HandleKeyDown(KeyboardEventArgs args) { + if (args.Key is "h" or "H") + { + _isHelpOpen = !_isHelpOpen; + return; + } + + if (_isHelpOpen) + { + if (args.Key == "Escape") + { + _isHelpOpen = false; + } + + return; + } + if (string.Equals(args.Key, "Tab", StringComparison.Ordinal)) { ChangeTab(args.ShiftKey ? -1 : 1); @@ -559,6 +607,127 @@ _ => Localization.Current.TabSettings }; + private HeaderSegment[] BuildHeaderSegments() + { + var width = Math.Max(UiMetrics.ConsoleWidth, 1); + var projectSegmentWidth = GetHeaderProjectSegment().Length; + var availableTabsWidth = Math.Max(width - projectSegmentWidth, 0); + if (availableTabsWidth <= 0) + { + return []; + } + + var labels = _tabs.Select(GetTabLabel).ToArray(); + var labelWidths = labels.Select(label => label.Length).ToArray(); + var availableLabelWidth = availableTabsWidth - (_tabs.Length * 2); + if (availableLabelWidth < _tabs.Length) + { + return + [ + new HeaderSegment( + FillBar(availableTabsWidth), + UiPalette.Text, + UiPalette.HeaderTabsBackground, + Spectre.Console.Decoration.None) + ]; + } + + while (labelWidths.Sum() > availableLabelWidth) + { + var largestIndex = Array.IndexOf(labelWidths, labelWidths.Max()); + if (largestIndex < 0 || labelWidths[largestIndex] <= 1) + { + break; + } + + labelWidths[largestIndex]--; + } + + var segments = new List(_tabs.Length + 1); + var usedWidth = 0; + + for (var i = 0; i < _tabs.Length; i++) + { + var tab = _tabs[i]; + var label = FitInline(labels[i], labelWidths[i]); + var content = WrapBarSegment(label); + + segments.Add(new HeaderSegment( + content, + tab == _activeTab ? UiPalette.SelectionForeground : UiPalette.Text, + tab == _activeTab ? UiPalette.SelectionBackground : UiPalette.HeaderTabsBackground, + tab == _activeTab ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)); + + usedWidth += content.Length; + } + + var fillerWidth = Math.Max(availableTabsWidth - usedWidth, 0); + if (fillerWidth > 0) + { + segments.Add(new HeaderSegment( + FillBar(fillerWidth), + UiPalette.Text, + UiPalette.HeaderTabsBackground, + Spectre.Console.Decoration.None)); + } + + return [.. segments]; + } + + private string GetHeaderProjectSegment() => WrapBarSegment(Localization.Current.ProjectTitle); + + private string GetHelpLine(Tab tab, string hint) => $"{GetTabLabel(tab)}: {hint}"; + + private string GetStatusBarLeftText() => $"{Localization.Label} | {Localization.Current.HelpStatusHint}"; + + private string GetStatusBarRightText() => $"{Localization.Current.DashboardEndpointLabel}: {_mcpEndpoint}"; + + private string BuildStatusBar(int width) + { + var left = GetStatusBarLeftText(); + var right = GetStatusBarRightText(); + width = Math.Max(width, 1); + + if (right.Length >= width) + { + return right[^width..]; + } + + var leftWidth = Math.Max(width - right.Length - 1, 0); + var fittedLeft = leftWidth == 0 ? string.Empty : PadRightVisible(FitInline(left, leftWidth), leftWidth); + + return leftWidth == 0 + ? PadLeftVisible(right, width) + : $"{fittedLeft} {right}"; + } + + private static string FillBar(int width) => + width <= 0 ? string.Empty : new string(Nbsp, width); + + private static string WrapBarSegment(string text) => $"{Nbsp}{text}{Nbsp}"; + + private static string PadRightVisible(string text, int width) + { + if (width <= 0) return string.Empty; + if (text.Length >= width) return text[..width]; + return text + FillBar(width - text.Length); + } + + private static string PadLeftVisible(string text, int width) + { + if (width <= 0) return string.Empty; + if (text.Length >= width) return text[^width..]; + return FillBar(width - text.Length) + text; + } + + private static string FitInline(string text, int width) + { + if (width <= 0) return string.Empty; + if (text.Length <= width) return text; + if (width <= 3) return text[..width]; + return text[..(width - 3)] + "..."; + } + public void Dispose() { Registry.StateChanged -= OnRegistryChanged; diff --git a/LazyBear.MCP/TUI/Components/LogsTab.razor b/LazyBear.MCP/TUI/Components/LogsTab.razor index cdd4a3b..2de5cfe 100644 --- a/LazyBear.MCP/TUI/Components/LogsTab.razor +++ b/LazyBear.MCP/TUI/Components/LogsTab.razor @@ -2,7 +2,6 @@ - @@ -87,7 +86,7 @@ ? selected.Message : $"{selected.Message} | {selected.Exception}"; - return Fit(details, Math.Max(Console.WindowWidth - 12, 32)); + return Fit(details, Math.Max(UiMetrics.ConsoleWidth - 12, 32)); } private string FormatEntry(int index) @@ -104,7 +103,7 @@ }; var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}"; - return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); + return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32)); } private static string Fit(string text, int width) diff --git a/LazyBear.MCP/TUI/Components/OverviewTab.razor b/LazyBear.MCP/TUI/Components/OverviewTab.razor index 3fd9f21..4a54647 100644 --- a/LazyBear.MCP/TUI/Components/OverviewTab.razor +++ b/LazyBear.MCP/TUI/Components/OverviewTab.razor @@ -1,8 +1,5 @@ - - - @if (Rows.Count == 0) @@ -32,7 +29,6 @@ [Parameter] public int SelectedIndex { get; set; } [Parameter] public EventCallback SelectedIndexChanged { get; set; } [Parameter] public int ViewportRows { get; set; } = 3; - [Parameter] public string Endpoint { get; set; } = ""; [Parameter] public TuiResources Loc { get; set; } = TuiResources.En; private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray(); @@ -56,7 +52,7 @@ var row = Rows[index]; var status = row.IsModuleEnabled ? $"[{Loc.StateOn}] " : $"[{Loc.StateOff}]"; var text = $"{row.ModuleName,-12} {status} {row.ConfiguredTools,2}/{row.TotalTools,-2} {row.Description}"; - return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); + return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32)); } private static string Fit(string text, int width) diff --git a/LazyBear.MCP/TUI/Components/SettingsTab.razor b/LazyBear.MCP/TUI/Components/SettingsTab.razor index 93769db..c4a450e 100644 --- a/LazyBear.MCP/TUI/Components/SettingsTab.razor +++ b/LazyBear.MCP/TUI/Components/SettingsTab.razor @@ -1,6 +1,5 @@ - @if (Entries.Count == 0) @@ -67,7 +66,7 @@ text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}"; } - return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); + return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32)); } private static string Fit(string text, int width) diff --git a/LazyBear.MCP/TUI/Localization/TuiResources.cs b/LazyBear.MCP/TUI/Localization/TuiResources.cs index 138d408..210246c 100644 --- a/LazyBear.MCP/TUI/Localization/TuiResources.cs +++ b/LazyBear.MCP/TUI/Localization/TuiResources.cs @@ -7,10 +7,15 @@ namespace LazyBear.MCP.TUI.Localization; public sealed record TuiResources { // ── Подсказка и вкладки ────────────────────────────────────────────────── + public string ProjectTitle { get; init; } = ""; public string HintBar { get; init; } = ""; public string TabOverview { get; init; } = ""; public string TabLogs { get; init; } = ""; public string TabSettings { get; init; } = ""; + public string HelpButtonLabel { get; init; } = ""; + public string HelpStatusHint { get; init; } = ""; + public string HelpModalTitle { get; init; } = ""; + public string HelpCloseHint { get; init; } = ""; // ── Dashboard ──────────────────────────────────────────────────────────── public string OverviewTitle { get; init; } = ""; @@ -44,10 +49,15 @@ public sealed record TuiResources public static readonly TuiResources En = new() { - HintBar = "Tab: tabs | Arrows: navigate | Space: toggle | Enter: open | L: language | Q: quit", - TabOverview = "Dashboard", - TabLogs = "Logs", - TabSettings = "Settings", + ProjectTitle = "LazyBear MCP", + HintBar = "Tab/Shift+Tab: switch tabs | H: help | L: language | Q: quit", + TabOverview = "Dashboard", + TabLogs = "Logs", + TabSettings = "Settings", + HelpButtonLabel = "Help", + HelpStatusHint = "H: help", + HelpModalTitle = "Keyboard Shortcuts", + HelpCloseHint = "H / Esc: close", OverviewTitle = "Dashboard", OverviewHint = "Up/Down: select module. Enter: open settings.", @@ -77,10 +87,15 @@ public sealed record TuiResources public static readonly TuiResources Ru = new() { - HintBar = "Tab: вкладки | Стрелки: навигация | Space: вкл/выкл | Enter: открыть | L: язык | Q: выход", - TabOverview = "Dashboard", - TabLogs = "Логи", - TabSettings = "Настройки", + ProjectTitle = "LazyBear MCP", + HintBar = "Tab/Shift+Tab: вкладки | H: справка | L: язык | Q: выход", + TabOverview = "Dashboard", + TabLogs = "Логи", + TabSettings = "Настройки", + HelpButtonLabel = "Справка", + HelpStatusHint = "H: справка", + HelpModalTitle = "Горячие клавиши", + HelpCloseHint = "H / Esc: закрыть", OverviewTitle = "Dashboard", OverviewHint = "Вверх/вниз: выбор модуля. Enter: открыть настройки.", diff --git a/LazyBear.MCP/TUI/UiMetrics.cs b/LazyBear.MCP/TUI/UiMetrics.cs new file mode 100644 index 0000000..729d89b --- /dev/null +++ b/LazyBear.MCP/TUI/UiMetrics.cs @@ -0,0 +1,43 @@ +using Spectre.Console; + +namespace LazyBear.MCP.TUI; + +internal static class UiMetrics +{ + public static int ConsoleWidth => Math.Max(ReadConsoleSize(ReadConsoleWidth, () => AnsiConsole.Profile.Width, 80), 1); + public static int ConsoleHeight => Math.Max(ReadConsoleSize(ReadConsoleHeight, () => AnsiConsole.Profile.Height, 24), 1); + + private static int ReadConsoleSize(Func consoleReader, Func profileReader, int fallback) + { + try + { + var consoleValue = consoleReader(); + if (consoleValue > 0) + { + return consoleValue; + } + } + catch + { + // Игнорируем и пробуем fallback через профиль Spectre. + } + + try + { + var profileValue = profileReader(); + if (profileValue > 0) + { + return profileValue; + } + } + catch + { + // Игнорируем и используем значение по умолчанию. + } + + return fallback; + } + + private static int ReadConsoleWidth() => Console.WindowWidth; + private static int ReadConsoleHeight() => Console.WindowHeight; +} diff --git a/LazyBear.MCP/TUI/UiPalette.cs b/LazyBear.MCP/TUI/UiPalette.cs index 6197042..5ba22cc 100644 --- a/LazyBear.MCP/TUI/UiPalette.cs +++ b/LazyBear.MCP/TUI/UiPalette.cs @@ -8,6 +8,8 @@ internal static class UiPalette 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 HeaderBrandBackground = new(9, 31, 47); + public static readonly Color HeaderTabsBackground = SurfaceMuted; public static readonly Color Accent = Color.Cyan1; public static readonly Color AccentSoft = Color.DeepSkyBlue1; public static readonly Color Text = Color.Grey93; diff --git a/LazyBear.MCP/logo.svg b/LazyBear.MCP/logo.svg new file mode 100644 index 0000000..8d289cc --- /dev/null +++ b/LazyBear.MCP/logo.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file