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:
@@ -2,80 +2,542 @@
|
||||
@using LazyBear.MCP.Services.ToolRegistry
|
||||
@inject ToolRegistryService Registry
|
||||
@inject InMemoryLogSink LogSink
|
||||
@inject GlobalKeyboardService KeyboardService
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
<Rows>
|
||||
<Panel Title="LazyBear MCP" BorderColor="@Spectre.Console.Color.Gold1" Expand="true">
|
||||
<Rows>
|
||||
@* Таб-навигация *@
|
||||
<Rows Expand="true">
|
||||
<Panel Title="LazyBear MCP"
|
||||
TitleColor="@UiPalette.Accent"
|
||||
BorderColor="@UiPalette.Frame"
|
||||
Expand="true"
|
||||
Height="@GetPanelHeight()"
|
||||
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
|
||||
<Rows Expand="true">
|
||||
<Markup Content="Tab: switch tabs | Arrows: navigate | Space: toggle | Enter: open" 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()" />
|
||||
}
|
||||
else if (_activeTab == Tab.Logs)
|
||||
{
|
||||
<LogsTab />
|
||||
<LogsTab Entries="@GetFilteredLogEntries()"
|
||||
SelectedIndex="@_logSelection"
|
||||
SelectedIndexChanged="@OnLogSelectionChanged"
|
||||
SelectedFilter="@_logFilters[_logFilterIndex]"
|
||||
ViewportRows="@GetLogsViewportRows()"
|
||||
IsStickyToBottom="@_logsStickToBottom" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<SettingsTab />
|
||||
<SettingsTab Entries="@GetSettingsEntries()"
|
||||
SelectedIndex="@_settingsSelection"
|
||||
SelectedIndexChanged="@OnSettingsSelectionChanged"
|
||||
ViewportRows="@GetSettingsViewportRows()" />
|
||||
}
|
||||
</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 int GetPanelHeight() => Math.Max(Console.WindowHeight - 2, 10);
|
||||
private static int GetOverviewViewportRows() => Math.Max(Console.WindowHeight - 11, 3);
|
||||
private static int GetLogsViewportRows() => Math.Max(Console.WindowHeight - 16, 5);
|
||||
private static int GetSettingsViewportRows() => Math.Max(Console.WindowHeight - 13, 5);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Registry.StateChanged += OnStateChanged;
|
||||
Registry.StateChanged += OnRegistryChanged;
|
||||
LogSink.OnLog += OnNewLog;
|
||||
KeyboardService.OnKeyPressed += OnConsoleKeyPressed;
|
||||
}
|
||||
|
||||
private void SetTab(Tab tab)
|
||||
// Конвертация ConsoleKeyInfo → KeyboardEventArgs для переиспользования существующей логики
|
||||
private static KeyboardEventArgs ConvertKey(ConsoleKeyInfo key)
|
||||
{
|
||||
_activeTab = tab;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnStateChanged()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnNewLog(LogEntry _)
|
||||
{
|
||||
if (_activeTab == Tab.Logs)
|
||||
var name = key.Key switch
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
ConsoleKey.UpArrow => "ArrowUp",
|
||||
ConsoleKey.DownArrow => "ArrowDown",
|
||||
ConsoleKey.LeftArrow => "ArrowLeft",
|
||||
ConsoleKey.RightArrow => "ArrowRight",
|
||||
ConsoleKey.Enter => "Enter",
|
||||
ConsoleKey.Spacebar => " ",
|
||||
ConsoleKey.Home => "Home",
|
||||
ConsoleKey.End => "End",
|
||||
ConsoleKey.PageUp => "PageUp",
|
||||
ConsoleKey.PageDown => "PageDown",
|
||||
ConsoleKey.Tab => "Tab",
|
||||
_ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString()
|
||||
};
|
||||
|
||||
return new KeyboardEventArgs
|
||||
{
|
||||
Key = name,
|
||||
ShiftKey = (key.Modifiers & ConsoleModifiers.Shift) != 0
|
||||
};
|
||||
}
|
||||
|
||||
private void OnConsoleKeyPressed(ConsoleKeyInfo key)
|
||||
{
|
||||
var args = ConvertKey(key);
|
||||
if (string.IsNullOrEmpty(args.Key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
HandleKeyDown(args);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (string.Equals(args.Key, "Tab", StringComparison.Ordinal))
|
||||
{
|
||||
ChangeTab(args.ShiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (_activeTab)
|
||||
{
|
||||
case Tab.Overview:
|
||||
HandleOverviewKey(args);
|
||||
break;
|
||||
case Tab.Logs:
|
||||
HandleLogsKey(args);
|
||||
break;
|
||||
case Tab.Settings:
|
||||
HandleSettingsKey(args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnOverviewSelectionChanged(int value)
|
||||
{
|
||||
var rows = GetOverviewRows();
|
||||
_overviewSelection = rows.Count == 0 ? 0 : Math.Clamp(value, 0, rows.Count - 1);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnLogSelectionChanged(int value)
|
||||
{
|
||||
var entries = GetFilteredLogEntries();
|
||||
_logSelection = entries.Count == 0 ? 0 : Math.Clamp(value, 0, entries.Count - 1);
|
||||
_logsStickToBottom = entries.Count == 0 || _logSelection >= entries.Count - 1;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnSettingsSelectionChanged(int value)
|
||||
{
|
||||
var entries = GetSettingsEntries();
|
||||
_settingsSelection = entries.Count == 0 ? 0 : Math.Clamp(value, 0, entries.Count - 1);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ChangeTab(int step)
|
||||
{
|
||||
var currentIndex = Array.IndexOf(_tabs, _activeTab);
|
||||
if (currentIndex < 0)
|
||||
{
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
var nextIndex = (currentIndex + step + _tabs.Length) % _tabs.Length;
|
||||
_activeTab = _tabs[nextIndex];
|
||||
ClampSelections();
|
||||
}
|
||||
|
||||
private void HandleOverviewKey(KeyboardEventArgs args)
|
||||
{
|
||||
var rows = GetOverviewRows();
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
_overviewSelection = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_overviewSelection = Math.Clamp(_overviewSelection, 0, rows.Count - 1);
|
||||
|
||||
switch (args.Key)
|
||||
{
|
||||
case "ArrowUp":
|
||||
_overviewSelection = Math.Max(0, _overviewSelection - 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
_overviewSelection = Math.Min(rows.Count - 1, _overviewSelection + 1);
|
||||
break;
|
||||
case "Home":
|
||||
_overviewSelection = 0;
|
||||
break;
|
||||
case "End":
|
||||
_overviewSelection = rows.Count - 1;
|
||||
break;
|
||||
case "Enter":
|
||||
_activeTab = Tab.Settings;
|
||||
SelectSettingsModule(rows[_overviewSelection].ModuleName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLogsKey(KeyboardEventArgs args)
|
||||
{
|
||||
switch (args.Key)
|
||||
{
|
||||
case "ArrowLeft":
|
||||
_logFilterIndex = (_logFilterIndex - 1 + _logFilters.Length) % _logFilters.Length;
|
||||
ResetLogsSelectionToBottom();
|
||||
return;
|
||||
case "ArrowRight":
|
||||
_logFilterIndex = (_logFilterIndex + 1) % _logFilters.Length;
|
||||
ResetLogsSelectionToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = GetFilteredLogEntries();
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
_logSelection = 0;
|
||||
_logsStickToBottom = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_logSelection = Math.Clamp(_logSelection, 0, entries.Count - 1);
|
||||
var page = Math.Max(GetLogsViewportRows() - 1, 1);
|
||||
|
||||
switch (args.Key)
|
||||
{
|
||||
case "ArrowUp":
|
||||
_logSelection = Math.Max(0, _logSelection - 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
_logSelection = Math.Min(entries.Count - 1, _logSelection + 1);
|
||||
break;
|
||||
case "PageUp":
|
||||
_logSelection = Math.Max(0, _logSelection - page);
|
||||
break;
|
||||
case "PageDown":
|
||||
case " ":
|
||||
case "Spacebar":
|
||||
_logSelection = Math.Min(entries.Count - 1, _logSelection + page);
|
||||
break;
|
||||
case "Home":
|
||||
_logSelection = 0;
|
||||
break;
|
||||
case "End":
|
||||
_logSelection = entries.Count - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
_logsStickToBottom = _logSelection >= entries.Count - 1;
|
||||
}
|
||||
|
||||
private void HandleSettingsKey(KeyboardEventArgs args)
|
||||
{
|
||||
var entries = GetSettingsEntries();
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
_settingsSelection = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_settingsSelection = Math.Clamp(_settingsSelection, 0, entries.Count - 1);
|
||||
var selected = entries[_settingsSelection];
|
||||
var page = Math.Max(GetSettingsViewportRows() - 1, 1);
|
||||
|
||||
switch (args.Key)
|
||||
{
|
||||
case "ArrowUp":
|
||||
_settingsSelection = Math.Max(0, _settingsSelection - 1);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
_settingsSelection = Math.Min(entries.Count - 1, _settingsSelection + 1);
|
||||
return;
|
||||
case "PageUp":
|
||||
_settingsSelection = Math.Max(0, _settingsSelection - page);
|
||||
return;
|
||||
case "PageDown":
|
||||
_settingsSelection = Math.Min(entries.Count - 1, _settingsSelection + page);
|
||||
return;
|
||||
case "Home":
|
||||
_settingsSelection = 0;
|
||||
return;
|
||||
case "End":
|
||||
_settingsSelection = entries.Count - 1;
|
||||
return;
|
||||
case "ArrowRight":
|
||||
ExpandModule(selected);
|
||||
return;
|
||||
case "ArrowLeft":
|
||||
CollapseModuleOrFocusParent(selected);
|
||||
return;
|
||||
case "Enter":
|
||||
ToggleExpansion(selected);
|
||||
return;
|
||||
case " ":
|
||||
case "Spacebar":
|
||||
ToggleSetting(selected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExpandModule(SettingsEntry entry)
|
||||
{
|
||||
if (entry.Kind != SettingsEntryKind.Module || entry.IsExpanded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_expandedModules.Add(entry.ModuleName);
|
||||
ClampSelections();
|
||||
}
|
||||
|
||||
private void CollapseModuleOrFocusParent(SettingsEntry entry)
|
||||
{
|
||||
if (entry.Kind == SettingsEntryKind.Module)
|
||||
{
|
||||
if (_expandedModules.Remove(entry.ModuleName))
|
||||
{
|
||||
ClampSelections();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_expandedModules.Remove(entry.ModuleName);
|
||||
SelectSettingsModule(entry.ModuleName);
|
||||
}
|
||||
|
||||
private void ToggleExpansion(SettingsEntry entry)
|
||||
{
|
||||
if (entry.Kind != SettingsEntryKind.Module)
|
||||
{
|
||||
ToggleSetting(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_expandedModules.Contains(entry.ModuleName))
|
||||
{
|
||||
_expandedModules.Remove(entry.ModuleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_expandedModules.Add(entry.ModuleName);
|
||||
}
|
||||
|
||||
SelectSettingsModule(entry.ModuleName);
|
||||
}
|
||||
|
||||
private void ToggleSetting(SettingsEntry entry)
|
||||
{
|
||||
if (entry.Kind == SettingsEntryKind.Module)
|
||||
{
|
||||
Registry.ToggleModule(entry.ModuleName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.ToolName))
|
||||
{
|
||||
Registry.ToggleTool(entry.ModuleName, entry.ToolName);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectSettingsModule(string moduleName)
|
||||
{
|
||||
var entries = GetSettingsEntries();
|
||||
var index = entries.FindIndex(entry =>
|
||||
entry.Kind == SettingsEntryKind.Module &&
|
||||
string.Equals(entry.ModuleName, moduleName, StringComparison.Ordinal));
|
||||
|
||||
_settingsSelection = index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
private void ResetLogsSelectionToBottom()
|
||||
{
|
||||
var entries = GetFilteredLogEntries();
|
||||
_logSelection = Math.Max(entries.Count - 1, 0);
|
||||
_logsStickToBottom = true;
|
||||
}
|
||||
|
||||
private List<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} (module is OFF, tool state is preserved)",
|
||||
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 static string GetTabLabel(Tab tab) => tab switch
|
||||
{
|
||||
Tab.Overview => "Overview",
|
||||
Tab.Logs => "Logs",
|
||||
_ => "Settings"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Registry.StateChanged -= OnStateChanged;
|
||||
Registry.StateChanged -= OnRegistryChanged;
|
||||
LogSink.OnLog -= OnNewLog;
|
||||
KeyboardService.OnKeyPressed -= OnConsoleKeyPressed;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user