Compare commits
6 Commits
380768b110
...
454d2a2f40
| Author | SHA1 | Date | |
|---|---|---|---|
| 454d2a2f40 | |||
| a7e912cac7 | |||
| 01565b32d9 | |||
| 4819fbca6c | |||
| 9b27cd7dc2 | |||
| 4bf267d681 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,3 +16,6 @@ obj/
|
||||
# Microsoft
|
||||
PublishFiles/
|
||||
temp
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
61
AGENT.tui.md
Normal file
61
AGENT.tui.md
Normal file
@@ -0,0 +1,61 @@
|
||||
## AGENT.tui.md
|
||||
|
||||
Read this file whenever you touch anything in `LazyBear.MCP/TUI/`.
|
||||
|
||||
### TUI Structure
|
||||
|
||||
- Entry point: `TUI/Components/App.razor` — owns all keyboard input and tab routing.
|
||||
- Keyboard input: `TUI/GlobalKeyboardService.cs` — single source; dedicated blocking thread on `Console.ReadKey(intercept: true)`. Do not add other console readers.
|
||||
- Localization: `TUI/Localization/` — `LocalizationService` singleton + `TuiResources` record with `En`/`Ru` static instances.
|
||||
- Components: `OverviewTab`, `LogsTab`, `SettingsTab` — pure display; receive state as parameters, fire no keyboard events.
|
||||
- Models: `TUI/Models/` — `OverviewRow`, `SettingsEntry`.
|
||||
- Palette: `TUI/UiPalette.cs` — all colors. Do not hardcode `Spectre.Console.Color` values in components.
|
||||
|
||||
### Keyboard Rules
|
||||
|
||||
- All keys are handled in `App.razor.HandleKeyDown()`. Do not attach `@onkeydown` to `<Select>` or any other RazorConsole component — framework intercepts Tab and arrows before they reach component-level callbacks.
|
||||
- `GlobalKeyboardService` fires `OnKeyPressed(ConsoleKeyInfo)`. `App.razor` converts via `ConvertKey()` and calls `HandleKeyDown()` inside `InvokeAsync`.
|
||||
- Do not use `Console.KeyAvailable` polling — it acquires the console mutex on every call and causes rendering lag. Always use blocking `Console.ReadKey` in a dedicated thread.
|
||||
- Keyboard shortcuts: `Tab`/`Shift+Tab` — switch tabs; Arrows — navigate list; `Space` — toggle; `Enter` — open/expand; `L` — cycle language.
|
||||
|
||||
### RazorConsole Gotchas (0.5.0)
|
||||
|
||||
- `@onkeydown` on `<Select>` is captured as `AdditionalAttributes` and not called for Tab/arrow keys.
|
||||
- `FocusManager` intercepts Tab globally — do not call `FocusNextAsync()` in `OnAfterRenderAsync` unconditionally; it shifts focus on every re-render.
|
||||
- No public global key-intercept API exists in 0.5.0.
|
||||
- `Select.Value` = committed selection (updated on Enter). `Select.FocusedValue` = highlighted item during navigation. Use `FocusedValue` to reflect external state immediately.
|
||||
- `StateHasChanged()` from a background thread must go through `InvokeAsync(() => { /* mutate state */ StateHasChanged(); })`.
|
||||
- Every `StateHasChanged()` triggers a full terminal redraw. Batch log-driven re-renders to avoid visible lag.
|
||||
|
||||
### Localization Rules
|
||||
|
||||
- All UI strings live in `TUI/Localization/TuiResources.cs` only. No hardcoded strings in `.razor` files or other `.cs` files.
|
||||
- To add a string: add a property to `TuiResources`, fill both `En` and `Ru` static instances. Build will fail if a `required init` is missing — by design.
|
||||
- Log level names (`Info`, `Warn`, `Error`) stay in English in all locales — they are technical identifiers, not UI labels.
|
||||
- Internal filter keys in `App.razor` (`"All"`, `"Info"`, `"Warn"`, `"Error"`) are English regardless of locale; `LogsTab` maps `"All"` → `Loc.FilterAll` for display.
|
||||
- Language toggle: `L` key cycles through locales. `LocalizationService.SwitchNext()` fires `OnChanged`; `App.razor` re-renders via `OnLocaleChanged()`.
|
||||
- Locale indicator shown in panel title: `"LazyBear MCP [EN]"` / `"LazyBear MCP [RU]"`.
|
||||
- Do not inject `LocalizationService` into child tab components — `App.razor` passes the current `TuiResources` as `Loc` parameter. Child components are stateless regarding locale.
|
||||
|
||||
### Component Contract
|
||||
|
||||
- Child tab components (`OverviewTab`, `LogsTab`, `SettingsTab`) accept `[Parameter] TuiResources Loc` — always pass it from `App.razor`.
|
||||
- Child components must not subscribe to events or inject services. Keep them as pure render components.
|
||||
- `SelectedIndexChanged` callbacks exist for forward-compatibility; actual selection state is managed exclusively in `App.razor`.
|
||||
|
||||
### DI Registration Pattern
|
||||
|
||||
Services that must be both injectable and run as hosted services:
|
||||
```csharp
|
||||
services.AddSingleton<MyService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<MyService>());
|
||||
```
|
||||
`AddHostedService<T>()` alone creates a transient instance — unusable as injectable singleton.
|
||||
|
||||
### Working Rules
|
||||
|
||||
- Read `App.razor` before touching any keyboard or tab logic.
|
||||
- Match the style of the file being edited.
|
||||
- After changes, run `dotnet build`.
|
||||
- Do not add new hardcoded strings to `.razor` files — add to `TuiResources` instead.
|
||||
- Do not add new `Console.KeyAvailable` or `Console.ReadKey` calls outside `GlobalKeyboardService`.
|
||||
@@ -42,11 +42,15 @@
|
||||
- Read related files before editing.
|
||||
- Prefer minimal, non-breaking changes.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### OpenCode: Question First
|
||||
- Follow `docs/opencode/question-policy.md` for the detailed `question` usage policy.
|
||||
### Documentation
|
||||
- **TUI work:** read `AGENT.tui.md` first — keyboard, localization, RazorConsole gotchas, component contract.
|
||||
- RazorConsole gotchas and session notes: `docs/tui_log.md`.
|
||||
- RazorConsole library docs: `docs/razorconsole/` (`overview.md`, `components.md`, `custom-translators.md`).
|
||||
- OpenCode question policy: `docs/opencode/question-policy.md`.
|
||||
|
||||
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Language Conventions
|
||||
|
||||
- **Output / explanations:** Russian
|
||||
- **Code:** English
|
||||
- **Comments, commit messages:** Russian
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build
|
||||
|
||||
# Run (starts TUI + HTTP MCP endpoint on port 5000)
|
||||
dotnet run --project LazyBear.MCP
|
||||
|
||||
# Test MCP tool wiring (only needed after changing transport or tool registration)
|
||||
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
|
||||
```
|
||||
|
||||
There are **no test projects**. After making changes, run `dotnet build`. If MCP wiring changed (new tool, new module, transport changes), also run the inspector.
|
||||
|
||||
Port is controlled via the `ASPNETCORE_URLS` environment variable (default `http://localhost:5000`). Ignore `launchSettings.json` — it shows a different port that is not used.
|
||||
|
||||
## Architecture
|
||||
|
||||
LazyBear is a .NET 10 MCP server exposing Jira, Confluence, and Kubernetes integrations to AI clients. It runs in two simultaneous modes that share a single DI container:
|
||||
|
||||
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
|
||||
|
||||
Both share the same singletons: `ToolRegistryService`, `InMemoryLogSink`, `GlobalKeyboardService`, and the three client providers.
|
||||
|
||||
### Plugin System (IToolModule)
|
||||
|
||||
Each integration implements `IToolModule` (in `Services/ToolRegistry/IToolModule.cs`) and declares its `ModuleName`, `ToolNames[]`, and `Description`. Tool classes are auto-discovered at startup via reflection using `WithToolsFromAssembly()` — classes are annotated with `[McpServerToolType]`, methods with `[McpServerTool]`. Registering a new module requires only: implement `IToolModule`, create tool classes with attributes, and add DI registration in `Program.cs`.
|
||||
|
||||
### ToolRegistryService
|
||||
|
||||
Singleton that tracks enabled/disabled state for both modules and individual tools at runtime (no restart needed). Uses `ConcurrentDictionary` for thread safety. Fires `StateChanged` event on toggles; TUI components subscribe to re-render. Tool keys use the format `"ModuleName::ToolName"`.
|
||||
|
||||
### Provider Pattern
|
||||
|
||||
Each client (`K8sClientProvider`, `JiraClientProvider`, `ConfluenceClientProvider`) is a lazy singleton. If initialization fails (missing config, unreachable endpoint), it captures an `InitializationError` string. **Tools return error strings instead of throwing exceptions** — this is intentional so MCP clients see the configuration issue gracefully.
|
||||
|
||||
### Logging
|
||||
|
||||
`InMemoryLogSink` maintains a 500-entry circular `ConcurrentQueue`. All .NET logs flow through `InMemoryLoggerProvider` → sink → `OnLog` event → TUI Logs tab live view.
|
||||
|
||||
### TUI Agent Reference
|
||||
|
||||
**Read `AGENT.tui.md` before making any changes to `TUI/`.** It covers keyboard handling, localization rules, RazorConsole gotchas, and component contract.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
## Configuration Gotchas
|
||||
|
||||
- **`Jira:Url`** is required; if missing, all Jira tools return string errors
|
||||
- **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<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
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `ModelContextProtocol.AspNetCore` 1.2.0 | HTTP MCP transport |
|
||||
| `KubernetesClient` 19.0.2 | K8s API |
|
||||
| `RestSharp` 112.0.0 | Jira / Confluence HTTP |
|
||||
| `RazorConsole.Core` 0.5.0 | Terminal UI framework |
|
||||
| `Polly` 8.4.2 | Retry/resilience policies |
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseAppHost>false</UseAppHost>
|
||||
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
|
||||
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -2,40 +2,91 @@ 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 LazyBear.MCP.TUI.Localization;
|
||||
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<K8sClientProvider>();
|
||||
builder.Services.AddSingleton<JiraClientProvider>();
|
||||
builder.Services.AddSingleton<ConfluenceClientProvider>();
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(logSink);
|
||||
services.AddSingleton<ToolRegistryService>();
|
||||
|
||||
// ── ToolRegistry ─────────────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ToolRegistryService>();
|
||||
// MCP-провайдеры
|
||||
services.AddSingleton<K8sClientProvider>();
|
||||
services.AddSingleton<JiraClientProvider>();
|
||||
services.AddSingleton<ConfluenceClientProvider>();
|
||||
|
||||
// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI)
|
||||
builder.Services.AddSingleton<IToolModule, JiraToolModule>();
|
||||
builder.Services.AddSingleton<IToolModule, KubernetesToolModule>();
|
||||
builder.Services.AddSingleton<IToolModule, ConfluenceToolModule>();
|
||||
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
|
||||
services.AddSingleton<IToolModule, JiraToolModule>();
|
||||
services.AddSingleton<IToolModule, KubernetesToolModule>();
|
||||
services.AddSingleton<IToolModule, ConfluenceToolModule>();
|
||||
|
||||
// ── MCP-сервер ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithToolsFromAssembly();
|
||||
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
|
||||
services.AddHostedService<McpWebHostedService>();
|
||||
|
||||
// ── TUI как фоновый сервис ───────────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<TuiHostedService>();
|
||||
// Глобальный читатель клавиш — единственный источник клавишных событий для TUI
|
||||
services.AddSingleton<GlobalKeyboardService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
|
||||
|
||||
var app = builder.Build();
|
||||
// Локализация TUI (en/ru, переключение клавишей L)
|
||||
services.AddSingleton<LocalizationService>();
|
||||
})
|
||||
.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.
|
||||
}
|
||||
|
||||
app.MapMcp();
|
||||
try
|
||||
{
|
||||
Console.Write("\u001b[?25l");
|
||||
Console.Out.Flush();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore terminals that do not support ANSI cursor control.
|
||||
}
|
||||
|
||||
var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000";
|
||||
app.Run(urls);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
});
|
||||
})
|
||||
.Build();
|
||||
|
||||
// ── Регистрируем модули один раз до старта TUI и web host ───────────────────
|
||||
var registry = host.Services.GetRequiredService<ToolRegistryService>();
|
||||
foreach (var module in host.Services.GetServices<IToolModule>())
|
||||
{
|
||||
registry.RegisterModule(module);
|
||||
}
|
||||
|
||||
await host.RunAsync();
|
||||
|
||||
66
LazyBear.MCP/Services/Mcp/McpWebHostedService.cs
Normal file
66
LazyBear.MCP/Services/Mcp/McpWebHostedService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
|
||||
@@ -2,80 +2,568 @@
|
||||
@using LazyBear.MCP.Services.ToolRegistry
|
||||
@inject ToolRegistryService Registry
|
||||
@inject InMemoryLogSink LogSink
|
||||
@inject GlobalKeyboardService KeyboardService
|
||||
@inject LocalizationService Localization
|
||||
@inject IHostApplicationLifetime AppLifetime
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
<Rows>
|
||||
<Panel Title="LazyBear MCP" BorderColor="@Spectre.Console.Color.Gold1" Expand="true">
|
||||
<Rows>
|
||||
@* Таб-навигация *@
|
||||
<Rows Expand="true">
|
||||
<Panel Title="@($"LazyBear MCP [{Localization.Label}]")"
|
||||
TitleColor="@UiPalette.Accent"
|
||||
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>
|
||||
<TextButton Content="[1] Overview"
|
||||
OnClick="@(() => SetTab(Tab.Overview))"
|
||||
BackgroundColor="@(_activeTab == Tab.Overview ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
|
||||
FocusedColor="@Spectre.Console.Color.Blue"
|
||||
FocusOrder="1" />
|
||||
<TextButton Content="[2] Logs"
|
||||
OnClick="@(() => SetTab(Tab.Logs))"
|
||||
BackgroundColor="@(_activeTab == Tab.Logs ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
|
||||
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" />
|
||||
@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 />
|
||||
<OverviewTab Rows="@GetOverviewRows()"
|
||||
SelectedIndex="@_overviewSelection"
|
||||
SelectedIndexChanged="@OnOverviewSelectionChanged"
|
||||
ViewportRows="@GetOverviewViewportRows()"
|
||||
Endpoint="@_mcpEndpoint"
|
||||
Loc="@Localization.Current" />
|
||||
}
|
||||
else if (_activeTab == Tab.Logs)
|
||||
{
|
||||
<LogsTab />
|
||||
<LogsTab Entries="@GetFilteredLogEntries()"
|
||||
SelectedIndex="@_logSelection"
|
||||
SelectedIndexChanged="@OnLogSelectionChanged"
|
||||
SelectedFilter="@_logFilters[_logFilterIndex]"
|
||||
ViewportRows="@GetLogsViewportRows()"
|
||||
IsStickyToBottom="@_logsStickToBottom"
|
||||
Loc="@Localization.Current" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<SettingsTab />
|
||||
<SettingsTab Entries="@GetSettingsEntries()"
|
||||
SelectedIndex="@_settingsSelection"
|
||||
SelectedIndexChanged="@OnSettingsSelectionChanged"
|
||||
ViewportRows="@GetSettingsViewportRows()"
|
||||
Loc="@Localization.Current" />
|
||||
}
|
||||
</Rows>
|
||||
</Panel>
|
||||
</Rows>
|
||||
|
||||
@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 int _overviewSelection;
|
||||
private int _logFilterIndex;
|
||||
private int _logSelection;
|
||||
private int _settingsSelection;
|
||||
private bool _logsStickToBottom = true;
|
||||
|
||||
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);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Registry.StateChanged += OnStateChanged;
|
||||
Registry.StateChanged += OnRegistryChanged;
|
||||
LogSink.OnLog += OnNewLog;
|
||||
KeyboardService.OnKeyPressed += OnConsoleKeyPressed;
|
||||
Localization.OnChanged += OnLocaleChanged;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnStateChanged()
|
||||
{
|
||||
private void OnLocaleChanged() =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (string.Equals(args.Key, "Tab", StringComparison.Ordinal))
|
||||
{
|
||||
ChangeTab(args.ShiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
private void OnNewLog(LogEntry _)
|
||||
if (args.Key is "l" or "L")
|
||||
{
|
||||
if (_activeTab == Tab.Logs)
|
||||
Localization.SwitchNext();
|
||||
return; // StateHasChanged вызовет OnLocaleChanged
|
||||
}
|
||||
|
||||
if (args.Key is "q" or "Q")
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
AppLifetime.StopApplication();
|
||||
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<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} {Localization.Current.ModuleOffPreserved}",
|
||||
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 string GetTabLabel(Tab tab) => tab switch
|
||||
{
|
||||
Tab.Overview => Localization.Current.TabOverview,
|
||||
Tab.Logs => Localization.Current.TabLogs,
|
||||
_ => Localization.Current.TabSettings
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Registry.StateChanged -= OnStateChanged;
|
||||
Registry.StateChanged -= OnRegistryChanged;
|
||||
LogSink.OnLog -= OnNewLog;
|
||||
KeyboardService.OnKeyPressed -= OnConsoleKeyPressed;
|
||||
Localization.OnChanged -= OnLocaleChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,99 @@
|
||||
@using LazyBear.MCP.Services.Logging
|
||||
@inject InMemoryLogSink LogSink
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
<Rows>
|
||||
<Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
|
||||
<Markup Content="@Loc.LogsHint" Foreground="@UiPalette.TextMuted" />
|
||||
<Markup Content=" " />
|
||||
|
||||
@* Фильтр по модулю *@
|
||||
<Columns>
|
||||
<Markup Content="Filter: " />
|
||||
<Select TItem="string"
|
||||
Options="@_filterOptions"
|
||||
Value="@_selectedFilter"
|
||||
ValueChanged="@OnFilterChanged"
|
||||
FocusOrder="10" />
|
||||
@foreach (var filter in Filters)
|
||||
{
|
||||
var isActive = string.Equals(filter, SelectedFilter, StringComparison.Ordinal);
|
||||
<Markup Content="@($" {FilterDisplay(filter)} ")"
|
||||
Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
|
||||
Background="@(isActive ? UiPalette.AccentSoft : UiPalette.SurfaceMuted)"
|
||||
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
|
||||
<Markup Content=" " />
|
||||
}
|
||||
</Columns>
|
||||
|
||||
<Markup Content=" " />
|
||||
|
||||
@{
|
||||
var entries = GetFilteredEntries();
|
||||
}
|
||||
|
||||
@if (entries.Count == 0)
|
||||
@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="@Loc.LogsEmpty" Foreground="@UiPalette.TextDim" />
|
||||
</Border>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Показываем последние 20 строк с прокруткой *@
|
||||
<ViewHeightScrollable LinesToRender="20"
|
||||
ScrollOffset="@_scrollOffset"
|
||||
ScrollOffsetChanged="@(v => { _scrollOffset = v; })" >
|
||||
<Rows>
|
||||
@foreach (var entry in entries)
|
||||
{
|
||||
var levelColor = entry.Level switch
|
||||
{
|
||||
LogLevel.Error or LogLevel.Critical => Spectre.Console.Color.Red,
|
||||
LogLevel.Warning => Spectre.Console.Color.Yellow,
|
||||
LogLevel.Information => Spectre.Console.Color.White,
|
||||
_ => Spectre.Console.Color.Grey
|
||||
};
|
||||
<Select TItem="int"
|
||||
Options="@GetOptions()"
|
||||
Value="@GetNormalizedIndex()"
|
||||
FocusedValue="@GetNormalizedIndex()"
|
||||
Formatter="@FormatEntry"
|
||||
Expand="true"
|
||||
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
|
||||
SelectedIndicator="@('>')" />
|
||||
}
|
||||
|
||||
var levelTag = entry.Level switch
|
||||
<Markup Content=" " />
|
||||
<Markup Content="@GetDetailsHeader()" Foreground="@UiPalette.TextMuted" />
|
||||
<Markup Content="@GetDetailsText()" Foreground="@UiPalette.Text" />
|
||||
</Rows>
|
||||
|
||||
@code {
|
||||
// Внутренние ключи фильтров — не локализуются (используются в логике App.razor)
|
||||
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; }
|
||||
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
|
||||
|
||||
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
|
||||
|
||||
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
|
||||
|
||||
// "All" локализуется; уровни логов (Info/Warn/Error) остаются на английском в любой локали
|
||||
private string FilterDisplay(string filter) =>
|
||||
filter == "All" ? Loc.FilterAll : filter;
|
||||
|
||||
private string GetDetailsHeader()
|
||||
{
|
||||
if (Entries.Count == 0)
|
||||
{
|
||||
return $"{Loc.FilterLabel}: {FilterDisplay(SelectedFilter)}";
|
||||
}
|
||||
|
||||
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
|
||||
var position = Math.Clamp(SelectedIndex, 0, Entries.Count - 1) + 1;
|
||||
var sticky = IsStickyToBottom ? Loc.LogsSticky : Loc.LogsManual;
|
||||
return $"{position}/{Entries.Count} | {selected.Timestamp:HH:mm:ss} | {selected.Level} | {selected.ShortCategory} | {sticky}";
|
||||
}
|
||||
|
||||
private string GetDetailsText()
|
||||
{
|
||||
if (Entries.Count == 0)
|
||||
{
|
||||
return Loc.LogsPlaceholder;
|
||||
}
|
||||
|
||||
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 string FormatEntry(int index)
|
||||
{
|
||||
var entry = Entries[index];
|
||||
var level = entry.Level switch
|
||||
{
|
||||
LogLevel.Error => "ERR",
|
||||
LogLevel.Critical => "CRT",
|
||||
@@ -53,67 +103,15 @@
|
||||
_ => "TRC"
|
||||
};
|
||||
|
||||
var time = entry.Timestamp.ToString("HH:mm:ss");
|
||||
var cat = entry.ShortCategory.Length > 18
|
||||
? 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>
|
||||
var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
|
||||
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
|
||||
}
|
||||
</Rows>
|
||||
</ViewHeightScrollable>
|
||||
}
|
||||
</Rows>
|
||||
|
||||
@code {
|
||||
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()
|
||||
private static string Fit(string text, int width)
|
||||
{
|
||||
["All"] = null,
|
||||
["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 LogSink.GetEntries(prefix);
|
||||
}
|
||||
|
||||
private void OnFilterChanged(string value)
|
||||
{
|
||||
_selectedFilter = value;
|
||||
_scrollOffset = 0;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
LogSink.OnLog += HandleNewLog;
|
||||
}
|
||||
|
||||
private void HandleNewLog(LogEntry _)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LogSink.OnLog -= HandleNewLog;
|
||||
if (width <= 0) return string.Empty;
|
||||
if (text.Length <= width) return text.PadRight(width);
|
||||
if (width <= 3) return text[..width];
|
||||
return text[..(width - 3)] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,69 @@
|
||||
@using LazyBear.MCP.Services.ToolRegistry
|
||||
@inject ToolRegistryService Registry
|
||||
|
||||
<Rows>
|
||||
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
|
||||
<Markup Content="@Loc.OverviewHint" Foreground="@UiPalette.TextMuted" />
|
||||
<Markup Content=" " />
|
||||
@foreach (var module in Registry.GetModules())
|
||||
<Markup Content="@($"{Loc.DashboardEndpointLabel}: {Endpoint}")" Foreground="@UiPalette.Accent" />
|
||||
<Markup Content=" " />
|
||||
|
||||
@if (Rows.Count == 0)
|
||||
{
|
||||
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);
|
||||
|
||||
<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>
|
||||
<Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
|
||||
<Markup Content="@Loc.OverviewEmpty" Foreground="@UiPalette.TextDim" />
|
||||
</Border>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Select TItem="int"
|
||||
Options="@GetOptions()"
|
||||
Value="@GetNormalizedIndex()"
|
||||
FocusedValue="@GetNormalizedIndex()"
|
||||
Formatter="@FormatRow"
|
||||
Expand="true"
|
||||
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
|
||||
SelectedIndicator="@('>')" />
|
||||
}
|
||||
|
||||
<Markup Content=" " />
|
||||
<Markup Content="[grey]Go to Settings tab to toggle modules and tools[/]" />
|
||||
<Markup Content="@GetFooterText()" Foreground="@UiPalette.TextMuted" />
|
||||
</Rows>
|
||||
|
||||
@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;
|
||||
[Parameter] public string Endpoint { get; set; } = "";
|
||||
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
|
||||
|
||||
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 Loc.OverviewEmpty;
|
||||
}
|
||||
|
||||
var selected = Rows[Math.Clamp(SelectedIndex, 0, Rows.Count - 1)];
|
||||
var state = selected.IsModuleEnabled ? Loc.StateOn : Loc.StateOff;
|
||||
return $"{selected.ModuleName}: {selected.Description} | {Loc.FooterModule} {state} | {Loc.FooterTools} {selected.ConfiguredTools}/{selected.TotalTools}";
|
||||
}
|
||||
|
||||
private string FormatRow(int index)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
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)] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,80 @@
|
||||
@using LazyBear.MCP.Services.ToolRegistry
|
||||
@inject ToolRegistryService Registry
|
||||
|
||||
<Rows>
|
||||
<Markup Content=" " />
|
||||
<Markup Content="[bold]Tool Registry — runtime enable/disable[/]" />
|
||||
<Markup Content="[grey]Changes take effect immediately without restart[/]" />
|
||||
<Markup Content="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
|
||||
<Markup Content="@Loc.SettingsHint" Foreground="@UiPalette.TextMuted" />
|
||||
<Markup Content=" " />
|
||||
|
||||
@{
|
||||
int focusIdx = 20;
|
||||
}
|
||||
|
||||
@foreach (var module in Registry.GetModules())
|
||||
@if (Entries.Count == 0)
|
||||
{
|
||||
var moduleEnabled = Registry.IsModuleEnabled(module.ModuleName);
|
||||
var moduleColor = moduleEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red;
|
||||
var moduleName = module.ModuleName;
|
||||
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=" " />
|
||||
<Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
|
||||
<Markup Content="@Loc.SettingsEmpty" Foreground="@UiPalette.TextDim" />
|
||||
</Border>
|
||||
}
|
||||
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>
|
||||
|
||||
@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;
|
||||
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
|
||||
|
||||
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 Loc.SettingsUnavailable;
|
||||
}
|
||||
|
||||
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
|
||||
? $" {Loc.ModuleOff}"
|
||||
: 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)] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
var moduleName = Module.ModuleName;
|
||||
var fo = StartFocusIdx + idx;
|
||||
|
||||
<TextButton Content="@(toolEnabled ? $"[green]✓[/] {toolName}" : $"[grey]✗[/] {toolName}")"
|
||||
<TextButton Content="@(toolEnabled ? $"✓ {toolName}" : $"✗ {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"
|
||||
FocusOrder="@fo" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.Extensions.Hosting
|
||||
@using RazorConsole.Components
|
||||
@using RazorConsole.Core
|
||||
@using RazorConsole.Core.Rendering
|
||||
@using LazyBear.MCP.TUI
|
||||
@using LazyBear.MCP.TUI.Localization
|
||||
@using LazyBear.MCP.TUI.Models
|
||||
@using LazyBear.MCP.TUI.Components
|
||||
|
||||
74
LazyBear.MCP/TUI/GlobalKeyboardService.cs
Normal file
74
LazyBear.MCP/TUI/GlobalKeyboardService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
7
LazyBear.MCP/TUI/Localization/Locale.cs
Normal file
7
LazyBear.MCP/TUI/Localization/Locale.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace LazyBear.MCP.TUI.Localization;
|
||||
|
||||
public enum Locale
|
||||
{
|
||||
En = 0,
|
||||
Ru = 1
|
||||
}
|
||||
25
LazyBear.MCP/TUI/Localization/LocalizationService.cs
Normal file
25
LazyBear.MCP/TUI/Localization/LocalizationService.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace LazyBear.MCP.TUI.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Синглтон, хранящий текущую локаль TUI. Переключение — клавиша L.
|
||||
/// Компоненты подписываются на OnChanged для перерисовки при смене языка.
|
||||
/// </summary>
|
||||
public sealed class LocalizationService
|
||||
{
|
||||
private static readonly TuiResources[] All = [TuiResources.En, TuiResources.Ru];
|
||||
private static readonly string[] Labels = ["EN", "RU"];
|
||||
|
||||
private int _index;
|
||||
|
||||
public TuiResources Current => All[_index];
|
||||
public string Label => Labels[_index];
|
||||
public Locale Locale => (Locale)_index;
|
||||
|
||||
public event Action? OnChanged;
|
||||
|
||||
public void SwitchNext()
|
||||
{
|
||||
_index = (_index + 1) % All.Length;
|
||||
OnChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
110
LazyBear.MCP/TUI/Localization/TuiResources.cs
Normal file
110
LazyBear.MCP/TUI/Localization/TuiResources.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
namespace LazyBear.MCP.TUI.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Все строки TUI для одной локали.
|
||||
/// При добавлении новой строки: добавить свойство сюда и перевод в оба статических экземпляра.
|
||||
/// </summary>
|
||||
public sealed record TuiResources
|
||||
{
|
||||
// ── Подсказка и вкладки ──────────────────────────────────────────────────
|
||||
public string HintBar { get; init; } = "";
|
||||
public string TabOverview { get; init; } = "";
|
||||
public string TabLogs { get; init; } = "";
|
||||
public string TabSettings { get; init; } = "";
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────
|
||||
public string OverviewTitle { get; init; } = "";
|
||||
public string OverviewHint { get; init; } = "";
|
||||
public string OverviewEmpty { get; init; } = "";
|
||||
public string DashboardEndpointLabel { get; init; } = "";
|
||||
public string StateOn { get; init; } = "";
|
||||
public string StateOff { get; init; } = "";
|
||||
public string FooterModule { get; init; } = "";
|
||||
public string FooterTools { get; init; } = "";
|
||||
|
||||
// ── Logs ─────────────────────────────────────────────────────────────────
|
||||
public string LogsTitle { get; init; } = "";
|
||||
public string LogsHint { get; init; } = "";
|
||||
public string LogsEmpty { get; init; } = "";
|
||||
public string LogsPlaceholder { get; init; } = "";
|
||||
public string LogsSticky { get; init; } = "";
|
||||
public string LogsManual { get; init; } = "";
|
||||
public string FilterLabel { get; init; } = "";
|
||||
public string FilterAll { get; init; } = "";
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
public string SettingsTitle { get; init; } = "";
|
||||
public string SettingsHint { get; init; } = "";
|
||||
public string SettingsEmpty { get; init; } = "";
|
||||
public string SettingsUnavailable { get; init; } = "";
|
||||
public string ModuleOff { get; init; } = "";
|
||||
public string ModuleOffPreserved { get; init; } = "";
|
||||
|
||||
// ── Локали ───────────────────────────────────────────────────────────────
|
||||
|
||||
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",
|
||||
|
||||
OverviewTitle = "Dashboard",
|
||||
OverviewHint = "Up/Down: select module. Enter: open settings.",
|
||||
OverviewEmpty = "No modules registered.",
|
||||
DashboardEndpointLabel = "MCP Endpoint",
|
||||
StateOn = "ON",
|
||||
StateOff = "OFF",
|
||||
FooterModule = "Module",
|
||||
FooterTools = "Tools",
|
||||
|
||||
LogsTitle = "Runtime Logs",
|
||||
LogsHint = "Left/Right: filter | Up/Down: scroll | PageUp/Down: page",
|
||||
LogsEmpty = "No log entries yet.",
|
||||
LogsPlaceholder = "Incoming log entries will appear here.",
|
||||
LogsSticky = "sticky",
|
||||
LogsManual = "manual",
|
||||
FilterLabel = "Filter",
|
||||
FilterAll = "All",
|
||||
|
||||
SettingsTitle = "Tool Registry",
|
||||
SettingsHint = "Up/Down: select | Left/Right: expand/collapse | Space: toggle",
|
||||
SettingsEmpty = "No modules available.",
|
||||
SettingsUnavailable = "Runtime enable/disable settings are unavailable.",
|
||||
ModuleOff = "(module off)",
|
||||
ModuleOffPreserved = "(module is OFF, tool state is preserved)"
|
||||
};
|
||||
|
||||
public static readonly TuiResources Ru = new()
|
||||
{
|
||||
HintBar = "Tab: вкладки | Стрелки: навигация | Space: вкл/выкл | Enter: открыть | L: язык | Q: выход",
|
||||
TabOverview = "Dashboard",
|
||||
TabLogs = "Логи",
|
||||
TabSettings = "Настройки",
|
||||
|
||||
OverviewTitle = "Dashboard",
|
||||
OverviewHint = "Вверх/вниз: выбор модуля. Enter: открыть настройки.",
|
||||
OverviewEmpty = "Нет зарегистрированных модулей.",
|
||||
DashboardEndpointLabel = "MCP Endpoint",
|
||||
StateOn = "ВКЛ",
|
||||
StateOff = "ВЫКЛ",
|
||||
FooterModule = "Модуль",
|
||||
FooterTools = "Инструменты",
|
||||
|
||||
LogsTitle = "Логи",
|
||||
LogsHint = "Лево/право: фильтр | Вверх/вниз: прокрутка | PageUp/Down: страница",
|
||||
LogsEmpty = "Записей пока нет.",
|
||||
LogsPlaceholder = "Новые записи будут появляться здесь.",
|
||||
LogsSticky = "следить",
|
||||
LogsManual = "вручную",
|
||||
FilterLabel = "Фильтр",
|
||||
FilterAll = "Все",
|
||||
|
||||
SettingsTitle = "Реестр инструментов",
|
||||
SettingsHint = "Вверх/вниз: выбор | Лево/право: развернуть/свернуть | Space: вкл/выкл",
|
||||
SettingsEmpty = "Нет доступных модулей.",
|
||||
SettingsUnavailable = "Настройки включения/выключения недоступны.",
|
||||
ModuleOff = "(модуль выкл)",
|
||||
ModuleOffPreserved = "(модуль ВЫКЛ, состояние инструментов сохранено)"
|
||||
};
|
||||
}
|
||||
8
LazyBear.MCP/TUI/Models/OverviewRow.cs
Normal file
8
LazyBear.MCP/TUI/Models/OverviewRow.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LazyBear.MCP.TUI.Models;
|
||||
|
||||
public sealed record OverviewRow(
|
||||
string ModuleName,
|
||||
string Description,
|
||||
bool IsModuleEnabled,
|
||||
int ConfiguredTools,
|
||||
int TotalTools);
|
||||
18
LazyBear.MCP/TUI/Models/SettingsEntry.cs
Normal file
18
LazyBear.MCP/TUI/Models/SettingsEntry.cs
Normal 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);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
21
LazyBear.MCP/TUI/UiPalette.cs
Normal file
21
LazyBear.MCP/TUI/UiPalette.cs
Normal 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);
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
| 📋 **Jira** | Работа с задачами, JQL, комментариями | ✅ Доступно |
|
||||
| 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно |
|
||||
| ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно |
|
||||
| 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно |
|
||||
| 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно |
|
||||
|
||||
---
|
||||
|
||||
|
||||
192
docs/tui_log.md
Normal file
192
docs/tui_log.md
Normal 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 эти вызовы бросают исключение.
|
||||
Reference in New Issue
Block a user