12 KiB
Terminal.Gui 终端 UI
所属项目: free-code .NET 10 重写 所属模块: UI 与扩展设计 原始代码:
../../src/screens/REPL.tsx+../../src/components/(50+ React/Ink 组件) 交叉参考: 核心模块设计 — 查询引擎 (QueryEngine)
概述
本模块用 Terminal.Gui 重新实现原始项目的 React/Ink 终端 UI 层。原始 REPL.tsx 是约 800 行的 React 函数式组件,依赖 Ink 的虚拟 DOM 将 JSX 渲染到终端。.NET 重写将其拆分为 TerminalApp(应用入口与主题)和 REPLScreen(主交互窗口),并将 50+ Ink 组件中最关键的两个(PermissionDialog、CompanionSpriteView)映射为 Terminal.Gui 的 Dialog 和 View 子类。
15.1 应用启动
TerminalApp 是整个 UI 的入口,对应原始 cli.tsx 到 REPL.tsx 的初始化链路。
/// <summary>
/// Terminal.Gui 应用入口
/// 对应原始 cli.tsx → REPL.tsx 的初始化
/// </summary>
public static class TerminalApp
{
public static void Run(IHost host)
{
Application.Init();
Application.UseSystemConsole = true;
// 全局主题
ApplyTheme();
// 创建主窗口
var top = Application.Top;
var replScreen = new REPLScreen(host.Services);
top.Add(replScreen);
// 全局键绑定
Application.KeyBindings.Add(Key.Esc, Command.Quit);
Application.KeyBindings.Add(Key.CtrlMask | Key.C, Command.Cancel);
Application.Run(top);
Application.Shutdown();
}
private static void ApplyTheme()
{
var colors = Colors.ColorSchemes["Base"];
colors.Normal = new Attribute(Color.White, Color.Black);
colors.Focus = new Attribute(Color.Green, Color.Black);
colors.HotNormal = new Attribute(Color.BrightCyan, Color.Black);
colors.HotFocus = new Attribute(Color.BrightGreen, Color.Black);
}
}
设计意图
Terminal.Gui 的 Application.Init() 对应 Ink 的 render() 调用。UseSystemConsole = true 确保在大多数终端模拟器下使用原生控制台输出,避免 Terminal.Gui 的 curses 后端与某些终端的兼容性问题。主题颜色选取刻意与原始 Ink 样式保持一致:白色前景、黑色背景、绿色高亮。
15.2 REPL 主屏幕
REPLScreen 对应原始 REPL.tsx 的全部交互逻辑,包括消息流渲染、斜杠命令路由和状态订阅。
/// <summary>
/// REPL 主屏幕 — Terminal.Gui 版本
/// 对应原始 REPL.tsx(~800 行 React/Ink 组件)
/// </summary>
public sealed class REPLScreen : Window
{
private readonly IQueryEngine _queryEngine;
private readonly IAppStateStore _stateStore;
private readonly ICommandRegistry _commands;
private readonly IBackgroundTaskManager _taskManager;
private readonly TextView _promptInput;
private readonly ListView _messageList;
private readonly StatusBar _statusBar;
private readonly Label _spinner;
private readonly IDisposable _stateSubscription;
public REPLScreen(IServiceProvider services) : base("free-code")
{
_queryEngine = services.GetRequiredService<IQueryEngine>();
_stateStore = services.GetRequiredService<IAppStateStore>();
_commands = services.GetRequiredService<ICommandRegistry>();
_taskManager = services.GetRequiredService<IBackgroundTaskManager>();
// 布局: 上方消息列表 + 下方输入框 + 底部状态栏
var messagesFrame = new FrameView("Messages")
{
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill() - 5
};
_messageList = new ListView()
{
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
};
messagesFrame.Add(_messageList);
_spinner = new Label("●")
{
X = 0, Y = Pos.Bottom(messagesFrame),
Visible = false
};
_promptInput = new TextView()
{
X = 2, Y = Pos.Bottom(messagesFrame),
Width = Dim.Fill(),
Height = 4,
};
_promptInput.KeyPress += OnPromptKeyPress;
_statusBar = new StatusBar()
{
X = 0, Y = Pos.Bottom(_promptInput),
};
Add(messagesFrame, _spinner, _promptInput, _statusBar);
// 订阅状态变更
_stateSubscription = _stateStore.Subscribe(OnStateChanged);
}
private void OnPromptKeyPress(KeyEventEventArgs e)
{
// Enter 提交, Shift+Enter 换行
if (e.KeyEvent.Key == Key.Enter && !e.KeyEvent.IsShift)
{
e.Handled = true;
var input = _promptInput.Text.ToString()?.Trim();
if (string.IsNullOrEmpty(input)) return;
// 斜杠命令检测
if (input.StartsWith('/'))
_ = HandleCommandAsync(input);
else
_ = HandleQueryAsync(input);
_promptInput.Text = "";
}
// Ctrl+C 取消
if (e.KeyEvent.Key == (Key.CtrlMask | Key.C))
{
_ = _queryEngine.CancelAsync();
e.Handled = true;
}
}
private async Task HandleQueryAsync(string input)
{
_spinner.Visible = true;
_spinner.Text = "◉ Thinking...";
try
{
await foreach (var msg in _queryEngine.SubmitMessageAsync(input))
{
switch (msg)
{
case SDKMessage.StreamingDelta delta:
UpdateAssistantMessage(delta.Text);
break;
case SDKMessage.ToolUseStart tus:
AddToolUseMessage(tus.Name, tus.Input);
break;
case SDKMessage.ToolUseResult tur:
UpdateToolResult(tur.ToolUseId, tur.Output);
break;
case SDKMessage.AssistantMessage:
_spinner.Visible = false;
break;
}
}
}
catch (OperationCanceledException)
{
AddSystemMessage("[Cancelled]");
}
_spinner.Visible = false;
}
private async Task HandleCommandAsync(string input)
{
var parts = input.Split(' ', 2);
var commandName = parts[0][1..]; // 去掉 '/'
var args = parts.Length > 1 ? parts[1] : null;
var commands = await _commands.GetEnabledCommandsAsync();
var command = commands.FirstOrDefault(c =>
c.Name == commandName || c.Aliases?.Contains(commandName) == true);
if (command == null)
{
AddSystemMessage($"Unknown command: /{commandName}");
return;
}
var result = await command.ExecuteAsync(
new CommandContext(_stateStore, _queryEngine), args);
if (result.Output != null)
AddSystemMessage(result.Output);
}
private void OnStateChanged(AppState state)
{
// 更新状态栏(须切回主线程)
Application.MainLoop.Invoke(() =>
{
var model = state.MainLoopModelForSession ?? "claude-sonnet-4-6";
var bgTasks = state.Tasks.Values
.Count(t => t.Status is TaskStatus.Running or TaskStatus.Pending);
_statusBar.Text =
$" Model: {model} | Tasks: {bgTasks} | Dir: {Directory.GetCurrentDirectory()}";
});
}
}
设计意图
原始 REPL.tsx 通过 React 状态(useState/useReducer)驱动 UI 更新。REPLScreen 将这套推送模型转换为 IAppStateStore.Subscribe 订阅回调,并通过 Application.MainLoop.Invoke 确保 UI 变更在主线程执行,与 Ink 的 React reconciler 在语义上等价。
消息流处理通过 await foreach 消费 IQueryEngine.SubmitMessageAsync 返回的 IAsyncEnumerable<SDKMessage>,完整保留原始流式渲染语义。Ctrl+C 调用 _queryEngine.CancelAsync() 对应原始组件中的 AbortController 取消机制。
15.3 组件库
对应原始 ../../src/components/ 目录中的 50+ React/Ink 组件。以下列出两个核心组件的 .NET 实现。
PermissionDialog
/// <summary>
/// 权限对话框 — 用户审批工具使用
/// 对应原始 PermissionRequest 组件
/// </summary>
public sealed class PermissionDialog : Dialog
{
public PermissionResponse Response { get; private set; }
public PermissionDialog(string toolName, string description, string input)
: base($"Permission: {toolName}", 60, 12)
{
var descLabel = new Label(description) { X = 1, Y = 1, Width = Dim.Fill() - 2 };
var inputLabel = new Label(Truncate(input, 200)) { X = 1, Y = 3, Width = Dim.Fill() - 2 };
var allowBtn = new Button("Allow (y)") { X = 5, Y = 8 };
var denyBtn = new Button("Deny (n)") { X = 25, Y = 8 };
var alwaysBtn = new Button("Always Allow (a)") { X = 40, Y = 8 };
allowBtn.Clicked += () => { Response = PermissionResponse.AllowOnce; RequestStop(); };
denyBtn.Clicked += () => { Response = PermissionResponse.Deny; RequestStop(); };
alwaysBtn.Clicked += () => { Response = PermissionResponse.AllowAlways; RequestStop(); };
Add(descLabel, inputLabel, allowBtn, denyBtn, alwaysBtn);
}
}
设计意图
原始 PermissionRequest 组件通过 React props 回调传递用户选择。PermissionDialog 改用 PermissionResponse 属性,调用方在 Application.Run(dialog) 返回后读取该属性,取得与原始回调语义等价的结果,但完全同步。
CompanionSpriteView
/// <summary>
/// 同伴精灵渲染 — ASCII art sprite
/// 对应原始 CompanionSprite 组件
/// </summary>
public sealed class CompanionSpriteView : View
{
private readonly Companion _companion;
public CompanionSpriteView(Companion companion)
{
_companion = companion;
Width = 20;
Height = 10;
}
public override void Draw()
{
base.Draw();
var sprite = CompanionSpriteRenderer.Render(_companion);
Move(0, 0);
for (var i = 0; i < sprite.Length; i++)
{
var line = sprite[i];
Driver.AddStr(line);
Move(0, i + 1);
}
}
}
设计意图
原始 CompanionSprite 是纯渲染组件,通过 Ink 的 <Text> 将 ASCII art 输出到终端。CompanionSpriteView 重写 Draw() 方法,用 Terminal.Gui 的 Driver.AddStr 逐行写入,保持与原始输出完全相同的视觉效果。CompanionSpriteRenderer.Render 静态方法封装精灵图案生成逻辑,独立于 UI 层测试。
组件映射一览
| 原始 Ink 组件 | .NET Terminal.Gui 实现 | 说明 |
|---|---|---|
REPL.tsx |
REPLScreen : Window |
主屏幕 |
PermissionRequest |
PermissionDialog : Dialog |
工具审批对话框 |
CompanionSprite |
CompanionSpriteView : View |
ASCII 精灵渲染 |
StatusBar |
StatusBar(Terminal.Gui 内置) |
底部状态栏 |
Spinner |
Label(动态文本) |
思考中指示器 |
MessageList |
ListView |
消息历史列表 |
PromptInput |
TextView |
多行输入框 |
其余 43+ 组件(FileDiff、TokenBudget、HistoryPicker 等)由对应的特性开关控制加载,在各自模块文档中描述。