diff --git a/AGENTS.md b/AGENTS.md index 2ccd34e..fde2e49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,11 +42,14 @@ - 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 +- RazorConsole gotchas and TUI keyboard notes: `docs/tui_log.md`. Read before touching TUI or input handling. +- RazorConsole library docs: `docs/razorconsole/` (`overview.md`, `components.md`, `custom-translators.md`). +- OpenCode question policy: `docs/opencode/question-policy.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 0f8d64c..4dd5f85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ LazyBear is a .NET 10 MCP server exposing Jira, Confluence, and Kubernetes integ 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`, and the three client providers. +Both share the same singletons: `ToolRegistryService`, `InMemoryLogSink`, `GlobalKeyboardService`, and the three client providers. ### Plugin System (IToolModule) @@ -50,6 +50,12 @@ Each client (`K8sClientProvider`, `JiraClientProvider`, `ConfluenceClientProvide `InMemoryLogSink` maintains a 500-entry circular `ConcurrentQueue`. All .NET logs flow through `InMemoryLoggerProvider` → sink → `OnLog` event → TUI Logs tab live view. +### 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 ` + @foreach (var filter in Filters) + { + var isActive = string.Equals(filter, SelectedFilter, StringComparison.Ordinal); + + + } - @{ - var entries = GetFilteredEntries(); - } - - @if (entries.Count == 0) + @if (Entries.Count == 0) { - + + + } else { - @* Показываем последние 20 строк с прокруткой *@ - - - @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; - - - - - - - - } - - + + } + - + @code { + [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = Array.Empty(); + [Parameter] public int SelectedIndex { get; set; } + [Parameter] public EventCallback SelectedIndexChanged { get; set; } + [Parameter] public int ViewportRows { get; set; } = 3; + + 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 "No integration modules available."; + } + + var selected = Rows[Math.Clamp(SelectedIndex, 0, Rows.Count - 1)]; + var state = selected.IsModuleEnabled ? "ON" : "OFF"; + return $"{selected.ModuleName}: {selected.Description} | Module {state} | Tools {selected.ConfiguredTools}/{selected.TotalTools}"; + } + + private static Spectre.Console.Color GetRowForeground(OverviewRow row) => + row.IsModuleEnabled ? UiPalette.Text : UiPalette.TextMuted; + + private string FormatRow(int index) + { + var row = Rows[index]; + var status = row.IsModuleEnabled ? "[ON] " : "[OFF]"; + 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)] + "..."; + } } diff --git a/LazyBear.MCP/TUI/Components/SettingsTab.razor b/LazyBear.MCP/TUI/Components/SettingsTab.razor index c85aa3a..bd78713 100644 --- a/LazyBear.MCP/TUI/Components/SettingsTab.razor +++ b/LazyBear.MCP/TUI/Components/SettingsTab.razor @@ -1,42 +1,89 @@ -@using LazyBear.MCP.Services.ToolRegistry -@inject ToolRegistryService Registry - - - - + + - @{ - 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++; - - - - - - - - - - - - - focusIdx += module.ToolNames.Count; - + + + } + else + { + ` из `RazorConsole.Components` с атрибутом `@onkeydown="OnKeyDown"`. `OnKeyDown` — это `EventCallback`, пробрасываемый из `App.razor`. + +### Корневая причина — RazorConsole 0.5.0 + +Изучили пакет `RazorConsole.Core 0.5.0` (XML-документация, структура NuGet): + +1. **`@onkeydown` на ``.** + Внутренняя инфраструктура 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(); +services.AddHostedService(sp => sp.GetRequiredService()); +``` + +### 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"` с `` + +- **`Value` / `ValueChanged`** — зафиксированный выбор. Обновляется только когда пользователь «подтверждает» (Enter). Не подходит для отслеживания позиции курсора в реальном времени. + +- **`FocusedValue` / `FocusedValueChanged`** — текущая подсвеченная строка при навигации стрелками. Нужно использовать именно этот параметр для синхронизации позиции курсора с внешним стейтом. + +- **`SelectedIndicator`** — символ рядом с текущей `FocusedValue`. Без явной установки `FocusedValue` индикатор может не отображаться или «застывать» на первом элементе. + +- **`@onkeydown` на `` для логики навигации. + +- **Прокрутка:** `Select` не принимает параметр `ViewportRows` явно — он сам управляет отображением. Для явного ограничения viewport нужен `>` или внешнее ограничение списка `Options`. + +#### `>` + +- Управляет пагинацией и клавишной навигацией (стрелки, PageUp/Down, Home/End) самостоятельно. +- `ChildContent` получает `ScrollContext` с видимыми элементами и (по документации) keyboard event handlers. Конкретные названия handler-ов в XML-документации не описаны — нужна инспекция DLL или исходники. +- `PageSize` — количество видимых элементов за раз (по умолчанию 1). + +#### `` + +- `OnInput` — срабатывает на **каждый** символ. Подходит для захвата произвольных нажатий если нужен «ввод текста». +- `OnSubmit` — Enter. +- Не использовать как скрытый «перехватчик» клавиш: он не перехватывает служебные клавиши (Tab, стрелки). + +#### `` + +- `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(); +services.AddHostedService(sp => sp.GetRequiredService()); +``` + +Альтернатива `AddHostedService()` регистрирует **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 эти вызовы бросают исключение.