Compare commits

..

2 Commits

10 changed files with 349 additions and 43 deletions

3
.gitignore vendored
View File

@@ -19,3 +19,6 @@ temp
# Claude Code
.claude/
LazyBear.MCP/LazyBear.MCP.csproj
/artifacts

View File

@@ -18,6 +18,9 @@
<PackageReference Include="RazorConsole.Core" Version="0.5.0" />
<PackageReference Include="RestSharp" Version="112.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<EmbeddedResource Include="logo.svg">
<LogicalView>LazyBear.MCP</LogicalView>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -9,37 +9,31 @@
@implements IDisposable
<Rows Expand="true">
<Panel Title="@($"LazyBear MCP [{Localization.Label}]")"
TitleColor="@UiPalette.Accent"
BorderColor="@UiPalette.Frame"
<Columns Expand="true">
<Markup Content="@GetHeaderProjectSegment()"
Foreground="@UiPalette.Text"
Background="@UiPalette.HeaderBrandBackground"
Decoration="@Spectre.Console.Decoration.Bold" />
@foreach (var segment in BuildHeaderSegments())
{
<Markup Content="@segment.Content"
Foreground="@segment.Foreground"
Background="@segment.Background"
Decoration="@segment.Decoration" />
}
</Columns>
<Panel BorderColor="@UiPalette.Frame"
Expand="true"
Height="@GetPanelHeight()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows Expand="true">
<Markup Content="@Localization.Current.HintBar" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@foreach (var tab in _tabs)
{
var isActive = _activeTab == tab;
<Markup Content="@($" {GetTabLabel(tab)} ")"
Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
Background="@(isActive ? UiPalette.Accent : UiPalette.SurfaceMuted)"
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
<Markup Content=" " />
}
</Columns>
<Markup Content=" " />
@if (_activeTab == Tab.Overview)
{
<OverviewTab Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()"
Endpoint="@_mcpEndpoint"
Loc="@Localization.Current" />
}
else if (_activeTab == Tab.Logs)
@@ -62,9 +56,38 @@
}
</Rows>
</Panel>
<Markup Content="@BuildStatusBar(UiMetrics.ConsoleWidth)"
Foreground="@UiPalette.Text"
Background="@UiPalette.SurfaceMuted" />
<ModalWindow IsOpened="@_isHelpOpen">
<Panel Title="@Localization.Current.HelpModalTitle"
TitleColor="@UiPalette.Accent"
BorderColor="@UiPalette.AccentSoft"
Width="@GetHelpModalWidth()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows>
<Markup Content="@Localization.Current.HintBar"
Foreground="@UiPalette.Text" />
<Markup Content=" " />
<Markup Content="@GetHelpLine(Tab.Overview, Localization.Current.OverviewHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetHelpLine(Tab.Logs, Localization.Current.LogsHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetHelpLine(Tab.Settings, Localization.Current.SettingsHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Markup Content="@Localization.Current.HelpCloseHint"
Foreground="@UiPalette.AccentSoft"
Decoration="@Spectre.Console.Decoration.Bold" />
</Rows>
</Panel>
</ModalWindow>
</Rows>
@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<string> _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<HeaderSegment>(_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;

View File

@@ -2,7 +2,6 @@
<Rows>
<Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.LogsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@@ -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)

View File

@@ -1,8 +1,5 @@
<Rows>
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.OverviewHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Markup Content="@($"{Loc.DashboardEndpointLabel}: {Endpoint}")" Foreground="@UiPalette.Accent" />
<Markup Content=" " />
@if (Rows.Count == 0)
@@ -32,7 +29,6 @@
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> 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)

View File

@@ -1,6 +1,5 @@
<Rows>
<Markup Content="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.SettingsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
@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)

View File

@@ -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",
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: выход",
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: открыть настройки.",

View File

@@ -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<int> consoleReader, Func<int> 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;
}

View File

@@ -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;

77
LazyBear.MCP/logo.svg Normal file
View File

@@ -0,0 +1,77 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#050816"/>
<stop offset="100%" stop-color="#0E0A22"/>
</linearGradient>
<linearGradient id="cyan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#F2FFFF"/>
<stop offset="50%" stop-color="#3EE8FF"/>
<stop offset="100%" stop-color="#0A8FE5"/>
</linearGradient>
<linearGradient id="visor" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#C7FFFF"/>
<stop offset="100%" stop-color="#9B6CFF"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="8" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- background -->
<rect width="1024" height="1024" rx="220" fill="url(#bg)"/>
<!-- BIGGER BEAR (scaled + centered) -->
<g transform="translate(512,560) scale(1.25) translate(-512,-560)">
<!-- ears -->
<circle cx="360" cy="300" r="90" fill="#0A101B" stroke="#4EE7FF" stroke-width="14"/>
<circle cx="664" cy="300" r="90" fill="#0A101B" stroke="#4EE7FF" stroke-width="14"/>
<!-- head -->
<path d="M260 380
C260 240 380 160 512 160
C644 160 764 240 764 380
C764 560 650 760 512 840
C374 760 260 560 260 380Z"
fill="#0A101B" stroke="#54E8FF" stroke-width="16"/>
<!-- face -->
<path d="M340 430
C360 330 430 290 512 290
C594 290 664 330 684 430
C694 500 660 610 590 670
C550 700 474 700 434 670
C364 610 330 500 340 430Z"
fill="url(#cyan)"/>
<!-- visor -->
<g filter="url(#glow)">
<path d="M360 370
C400 330 624 330 664 370
L664 450
C620 480 404 480 360 450Z"
fill="#0B1528" stroke="url(#visor)" stroke-width="12"/>
</g>
<!-- nose -->
<ellipse cx="512" cy="500" rx="40" ry="26" fill="#0E121A"/>
<!-- simplified mouth for small sizes -->
<path d="M440 580
C470 610 554 610 584 580"
fill="none" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<!-- teeth (simplified) -->
<path d="M470 585 L490 615 L510 585 L530 615 L550 585"
fill="none" stroke="#EFFFFF" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB