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

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