feat: добавить локализацию TUI (en/ru) с переключением клавишей L

- Добавлены TuiResources (sealed record), Locale, LocalizationService
- Все строки интерфейса вынесены из .razor-файлов в TuiResources
- App.razor: клавиша L циклически переключает локаль, заголовок показывает [EN]/[RU]
- Дочерние компоненты получают Loc как параметр (stateless)
- Создан AGENT.tui.md с правилами работы с TUI для агентов
- Обновлены AGENTS.md и CLAUDE.md со ссылками на AGENT.tui.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 23:53:59 +03:00
parent 4819fbca6c
commit 01565b32d9
12 changed files with 279 additions and 91 deletions

View File

@@ -3,18 +3,19 @@
@inject ToolRegistryService Registry
@inject InMemoryLogSink LogSink
@inject GlobalKeyboardService KeyboardService
@inject LocalizationService Localization
@implements IDisposable
<Rows Expand="true">
<Panel Title="LazyBear MCP"
<Panel Title="@($"LazyBear MCP [{Localization.Label}]")"
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="@Localization.Current.HintBar" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@@ -36,7 +37,8 @@
<OverviewTab Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()" />
ViewportRows="@GetOverviewViewportRows()"
Loc="@Localization.Current" />
}
else if (_activeTab == Tab.Logs)
{
@@ -45,14 +47,16 @@
SelectedIndexChanged="@OnLogSelectionChanged"
SelectedFilter="@_logFilters[_logFilterIndex]"
ViewportRows="@GetLogsViewportRows()"
IsStickyToBottom="@_logsStickToBottom" />
IsStickyToBottom="@_logsStickToBottom"
Loc="@Localization.Current" />
}
else
{
<SettingsTab Entries="@GetSettingsEntries()"
SelectedIndex="@_settingsSelection"
SelectedIndexChanged="@OnSettingsSelectionChanged"
ViewportRows="@GetSettingsViewportRows()" />
ViewportRows="@GetSettingsViewportRows()"
Loc="@Localization.Current" />
}
</Rows>
</Panel>
@@ -87,6 +91,7 @@
Registry.StateChanged += OnRegistryChanged;
LogSink.OnLog += OnNewLog;
KeyboardService.OnKeyPressed += OnConsoleKeyPressed;
Localization.OnChanged += OnLocaleChanged;
}
// Конвертация ConsoleKeyInfo → KeyboardEventArgs для переиспользования существующей логики
@@ -130,6 +135,9 @@
});
}
private void OnLocaleChanged() =>
InvokeAsync(StateHasChanged);
private void HandleKeyDown(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Tab", StringComparison.Ordinal))
@@ -138,6 +146,12 @@
return;
}
if (args.Key is "l" or "L")
{
Localization.SwitchNext();
return; // StateHasChanged вызовет OnLocaleChanged
}
switch (_activeTab)
{
case Tab.Overview:
@@ -447,7 +461,7 @@
toolName,
isModuleEnabled
? $"{module.ModuleName} / {toolName}"
: $"{module.ModuleName} / {toolName} (module is OFF, tool state is preserved)",
: $"{module.ModuleName} / {toolName} {Localization.Current.ModuleOffPreserved}",
isConfigured,
isModuleEnabled,
false,
@@ -527,11 +541,11 @@
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
private string GetTabLabel(Tab tab) => tab switch
{
Tab.Overview => "Overview",
Tab.Logs => "Logs",
_ => "Settings"
Tab.Overview => Localization.Current.TabOverview,
Tab.Logs => Localization.Current.TabLogs,
_ => Localization.Current.TabSettings
};
public void Dispose()
@@ -539,5 +553,6 @@
Registry.StateChanged -= OnRegistryChanged;
LogSink.OnLog -= OnNewLog;
KeyboardService.OnKeyPressed -= OnConsoleKeyPressed;
Localization.OnChanged -= OnLocaleChanged;
}
}

View File

@@ -1,15 +1,15 @@
@using LazyBear.MCP.Services.Logging
<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="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.LogsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@foreach (var filter in Filters)
{
var isActive = string.Equals(filter, SelectedFilter, StringComparison.Ordinal);
<Markup Content="@($" {filter} ")"
<Markup Content="@($" {FilterDisplay(filter)} ")"
Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
Background="@(isActive ? UiPalette.AccentSoft : UiPalette.SurfaceMuted)"
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
@@ -22,7 +22,7 @@
@if (Entries.Count == 0)
{
<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" />
<Markup Content="@Loc.LogsEmpty" Foreground="@UiPalette.TextDim" />
</Border>
}
else
@@ -43,6 +43,7 @@
</Rows>
@code {
// Внутренние ключи фильтров — не локализуются (используются в логике App.razor)
private static readonly string[] Filters = ["All", "Info", "Warn", "Error"];
[Parameter, EditorRequired] public IReadOnlyList<LogEntry> Entries { get; set; } = Array.Empty<LogEntry>();
@@ -51,21 +52,26 @@
[Parameter] public string SelectedFilter { get; set; } = "All";
[Parameter] public int ViewportRows { get; set; } = 5;
[Parameter] public bool IsStickyToBottom { get; set; }
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
// "All" локализуется; уровни логов (Info/Warn/Error) остаются на английском в любой локали
private string FilterDisplay(string filter) =>
filter == "All" ? Loc.FilterAll : filter;
private string GetDetailsHeader()
{
if (Entries.Count == 0)
{
return $"Filter: {SelectedFilter}";
return $"{Loc.FilterLabel}: {FilterDisplay(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";
var sticky = IsStickyToBottom ? Loc.LogsSticky : Loc.LogsManual;
return $"{position}/{Entries.Count} | {selected.Timestamp:HH:mm:ss} | {selected.Level} | {selected.ShortCategory} | {sticky}";
}
@@ -73,7 +79,7 @@
{
if (Entries.Count == 0)
{
return "Incoming log entries will appear here.";
return Loc.LogsPlaceholder;
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
@@ -84,25 +90,17 @@
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 string FormatEntry(int index)
{
var entry = Entries[index];
var level = entry.Level switch
{
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
LogLevel.Warning => "WRN",
LogLevel.Information => "INF",
LogLevel.Debug => "DBG",
_ => "TRC"
LogLevel.Debug => "DBG",
_ => "TRC"
};
var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
@@ -111,21 +109,9 @@
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];
}
if (width <= 0) return string.Empty;
if (text.Length <= width) return text.PadRight(width);
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
}

View File

@@ -1,12 +1,12 @@
<Rows>
<Markup Content="Module Overview" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="Up/Down select a module. Enter opens its settings." Foreground="@UiPalette.TextMuted" />
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.OverviewHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
@if (Rows.Count == 0)
{
<Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
<Markup Content="No modules registered." Foreground="@UiPalette.TextDim" />
<Markup Content="@Loc.OverviewEmpty" Foreground="@UiPalette.TextDim" />
</Border>
}
else
@@ -30,6 +30,7 @@
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 3;
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray();
@@ -39,42 +40,27 @@
{
if (Rows.Count == 0)
{
return "No integration modules available.";
return Loc.OverviewEmpty;
}
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}";
var state = selected.IsModuleEnabled ? Loc.StateOn : Loc.StateOff;
return $"{selected.ModuleName}: {selected.Description} | {Loc.FooterModule} {state} | {Loc.FooterTools} {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 status = row.IsModuleEnabled ? $"[{Loc.StateOn}] " : $"[{Loc.StateOff}]";
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];
}
if (width <= 0) return string.Empty;
if (text.Length <= width) return text.PadRight(width);
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
}

View File

@@ -1,12 +1,12 @@
<Rows>
<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="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.SettingsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
@if (Entries.Count == 0)
{
<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" />
<Markup Content="@Loc.SettingsEmpty" Foreground="@UiPalette.TextDim" />
</Border>
}
else
@@ -30,6 +30,7 @@
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 5;
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
@@ -39,7 +40,7 @@
{
if (Entries.Count == 0)
{
return "Runtime enable/disable settings are unavailable.";
return Loc.SettingsUnavailable;
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
@@ -51,7 +52,9 @@
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;
var disabledSuffix = entry.Kind == SettingsEntryKind.Tool && !entry.IsModuleEnabled
? $" {Loc.ModuleOff}"
: string.Empty;
string text;
if (entry.Kind == SettingsEntryKind.Module)
@@ -69,21 +72,9 @@
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];
}
if (width <= 0) return string.Empty;
if (text.Length <= width) return text.PadRight(width);
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
}

View File

@@ -4,5 +4,6 @@
@using RazorConsole.Core
@using RazorConsole.Core.Rendering
@using LazyBear.MCP.TUI
@using LazyBear.MCP.TUI.Localization
@using LazyBear.MCP.TUI.Models
@using LazyBear.MCP.TUI.Components

View File

@@ -0,0 +1,7 @@
namespace LazyBear.MCP.TUI.Localization;
public enum Locale
{
En = 0,
Ru = 1
}

View File

@@ -0,0 +1,25 @@
namespace LazyBear.MCP.TUI.Localization;
/// <summary>
/// Синглтон, хранящий текущую локаль TUI. Переключение — клавиша L.
/// Компоненты подписываются на OnChanged для перерисовки при смене языка.
/// </summary>
public sealed class LocalizationService
{
private static readonly TuiResources[] All = [TuiResources.En, TuiResources.Ru];
private static readonly string[] Labels = ["EN", "RU"];
private int _index;
public TuiResources Current => All[_index];
public string Label => Labels[_index];
public Locale Locale => (Locale)_index;
public event Action? OnChanged;
public void SwitchNext()
{
_index = (_index + 1) % All.Length;
OnChanged?.Invoke();
}
}

View File

@@ -0,0 +1,107 @@
namespace LazyBear.MCP.TUI.Localization;
/// <summary>
/// Все строки TUI для одной локали.
/// При добавлении новой строки: добавить свойство сюда и перевод в оба статических экземпляра.
/// </summary>
public sealed record TuiResources
{
// ── Подсказка и вкладки ──────────────────────────────────────────────────
public string HintBar { get; init; } = "";
public string TabOverview { get; init; } = "";
public string TabLogs { get; init; } = "";
public string TabSettings { get; init; } = "";
// ── Overview ─────────────────────────────────────────────────────────────
public string OverviewTitle { get; init; } = "";
public string OverviewHint { get; init; } = "";
public string OverviewEmpty { get; init; } = "";
public string StateOn { get; init; } = "";
public string StateOff { get; init; } = "";
public string FooterModule { get; init; } = "";
public string FooterTools { get; init; } = "";
// ── Logs ─────────────────────────────────────────────────────────────────
public string LogsTitle { get; init; } = "";
public string LogsHint { get; init; } = "";
public string LogsEmpty { get; init; } = "";
public string LogsPlaceholder { get; init; } = "";
public string LogsSticky { get; init; } = "";
public string LogsManual { get; init; } = "";
public string FilterLabel { get; init; } = "";
public string FilterAll { get; init; } = "";
// ── Settings ─────────────────────────────────────────────────────────────
public string SettingsTitle { get; init; } = "";
public string SettingsHint { get; init; } = "";
public string SettingsEmpty { get; init; } = "";
public string SettingsUnavailable { get; init; } = "";
public string ModuleOff { get; init; } = "";
public string ModuleOffPreserved { get; init; } = "";
// ── Локали ───────────────────────────────────────────────────────────────
public static readonly TuiResources En = new()
{
HintBar = "Tab: tabs | Arrows: navigate | Space: toggle | Enter: open | L: language",
TabOverview = "Overview",
TabLogs = "Logs",
TabSettings = "Settings",
OverviewTitle = "Module Overview",
OverviewHint = "Up/Down: select module. Enter: open settings.",
OverviewEmpty = "No modules registered.",
StateOn = "ON",
StateOff = "OFF",
FooterModule = "Module",
FooterTools = "Tools",
LogsTitle = "Runtime Logs",
LogsHint = "Left/Right: filter | Up/Down: scroll | PageUp/Down: page",
LogsEmpty = "No log entries yet.",
LogsPlaceholder = "Incoming log entries will appear here.",
LogsSticky = "sticky",
LogsManual = "manual",
FilterLabel = "Filter",
FilterAll = "All",
SettingsTitle = "Tool Registry",
SettingsHint = "Up/Down: select | Left/Right: expand/collapse | Space: toggle",
SettingsEmpty = "No modules available.",
SettingsUnavailable = "Runtime enable/disable settings are unavailable.",
ModuleOff = "(module off)",
ModuleOffPreserved = "(module is OFF, tool state is preserved)"
};
public static readonly TuiResources Ru = new()
{
HintBar = "Tab: вкладки | Стрелки: навигация | Space: вкл/выкл | Enter: открыть | L: язык",
TabOverview = "Обзор",
TabLogs = "Логи",
TabSettings = "Настройки",
OverviewTitle = "Модули",
OverviewHint = "Вверх/вниз: выбор модуля. Enter: открыть настройки.",
OverviewEmpty = "Нет зарегистрированных модулей.",
StateOn = "ВКЛ",
StateOff = "ВЫКЛ",
FooterModule = "Модуль",
FooterTools = "Инструменты",
LogsTitle = "Логи",
LogsHint = "Лево/право: фильтр | Вверх/вниз: прокрутка | PageUp/Down: страница",
LogsEmpty = "Записей пока нет.",
LogsPlaceholder = "Новые записи будут появляться здесь.",
LogsSticky = "следить",
LogsManual = "вручную",
FilterLabel = "Фильтр",
FilterAll = "Все",
SettingsTitle = "Реестр инструментов",
SettingsHint = "Вверх/вниз: выбор | Лево/право: развернуть/свернуть | Space: вкл/выкл",
SettingsEmpty = "Нет доступных модулей.",
SettingsUnavailable = "Настройки включения/выключения недоступны.",
ModuleOff = "(модуль выкл)",
ModuleOffPreserved = "(модуль ВЫКЛ, состояние инструментов сохранено)"
};
}