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,119 +1,131 @@
@using LazyBear.MCP.Services.Logging
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows>
<Markup Content="Runtime Logs" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="Left/Right change level. Up/Down move. PageUp/PageDown/Home/End scroll." 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="@($" {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="No log entries yet." 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
};
var levelTag = entry.Level switch
{
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
LogLevel.Warning => "WRN",
LogLevel.Information => "INF",
LogLevel.Debug => "DBG",
_ => "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>
}
</Rows>
</ViewHeightScrollable>
<Select TItem="int"
Options="@GetOptions()"
Value="@GetNormalizedIndex()"
FocusedValue="@GetNormalizedIndex()"
Formatter="@FormatEntry"
Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " />
<Markup Content="@GetDetailsHeader()" Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetDetailsText()" Foreground="@UiPalette.Text" />
</Rows>
@code {
private string _selectedFilter = "All";
private int _scrollOffset = 0;
private static readonly string[] Filters = ["All", "Info", "Warn", "Error"];
private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"];
[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; }
private static readonly Dictionary<string, string?> FilterPrefixes = new()
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 GetDetailsHeader()
{
["All"] = null,
["Jira"] = "LazyBear.MCP.Services.Jira",
["Kubernetes"] = "LazyBear.MCP.Services.Kubernetes",
["Confluence"] = "LazyBear.MCP.Services.Confluence",
["MCP"] = "ModelContextProtocol",
["System"] = "Microsoft"
if (Entries.Count == 0)
{
return $"Filter: {SelectedFilter}";
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
var position = Math.Clamp(SelectedIndex, 0, Entries.Count - 1) + 1;
var sticky = IsStickyToBottom ? "sticky" : "manual";
return $"{position}/{Entries.Count} | {selected.Timestamp:HH:mm:ss} | {selected.Level} | {selected.ShortCategory} | {sticky}";
}
private string GetDetailsText()
{
if (Entries.Count == 0)
{
return "Incoming log entries will appear here.";
}
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 static Spectre.Console.Color GetLevelColor(LogEntry entry) => entry.Level switch
{
LogLevel.Error or LogLevel.Critical => UiPalette.Danger,
LogLevel.Warning => UiPalette.Warning,
LogLevel.Information => UiPalette.Text,
_ => UiPalette.TextMuted
};
private IReadOnlyList<LogEntry> GetFilteredEntries()
private string FormatEntry(int index)
{
FilterPrefixes.TryGetValue(_selectedFilter, out var prefix);
return LogSink.GetEntries(prefix);
var entry = Entries[index];
var level = entry.Level switch
{
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
LogLevel.Warning => "WRN",
LogLevel.Information => "INF",
LogLevel.Debug => "DBG",
_ => "TRC"
};
var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
}
private void OnFilterChanged(string value)
private static string Fit(string text, int width)
{
_selectedFilter = value;
_scrollOffset = 0;
StateHasChanged();
}
if (width <= 0)
{
return string.Empty;
}
protected override void OnInitialized()
{
LogSink.OnLog += HandleNewLog;
}
if (text.Length <= width)
{
return text.PadRight(width);
}
private void HandleNewLog(LogEntry _)
{
InvokeAsync(StateHasChanged);
}
if (width <= 3)
{
return text[..width];
}
public void Dispose()
{
LogSink.OnLog -= HandleNewLog;
return text[..(width - 3)] + "...";
}
}