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

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

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

View File

@@ -1,42 +1,89 @@
@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="Tool Registry" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="Up/Down select. Left/Right collapse or expand. Space toggles state." 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="No modules available." 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;
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
private string GetSelectedDescription()
{
if (Entries.Count == 0)
{
return "Runtime enable/disable settings are unavailable.";
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
return selected.Description;
}
private string FormatEntry(int index)
{
var entry = Entries[index];
var indent = new string(' ', entry.Depth * 4);
var checkbox = entry.IsChecked ? "[x]" : "[ ]";
var disabledSuffix = entry.Kind == SettingsEntryKind.Tool && !entry.IsModuleEnabled ? " (module off)" : string.Empty;
string text;
if (entry.Kind == SettingsEntryKind.Module)
{
var expander = entry.IsExpanded ? "[-]" : "[+]";
text = $"{expander} {checkbox} {entry.Label}";
}
else
{
text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}";
}
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
}
private static string Fit(string text, int width)
{
if (width <= 0)
{
return string.Empty;
}
if (text.Length <= width)
{
return text.PadRight(width);
}
if (width <= 3)
{
return text[..width];
}
return text[..(width - 3)] + "...";
}
}