# Terminal.Gui 终端 UI
> 所属项目: free-code .NET 10 重写
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
> 原始代码: `../../src/screens/REPL.tsx` + `../../src/components/`(50+ React/Ink 组件)
> 交叉参考: [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
---
## 概述
本模块用 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` 的初始化链路。
```csharp
///
/// Terminal.Gui 应用入口
/// 对应原始 cli.tsx → REPL.tsx 的初始化
///
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` 的全部交互逻辑,包括消息流渲染、斜杠命令路由和状态订阅。
```csharp
///
/// REPL 主屏幕 — Terminal.Gui 版本
/// 对应原始 REPL.tsx(~800 行 React/Ink 组件)
///
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();
_stateStore = services.GetRequiredService();
_commands = services.GetRequiredService();
_taskManager = services.GetRequiredService();
// 布局: 上方消息列表 + 下方输入框 + 底部状态栏
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`,完整保留原始流式渲染语义。Ctrl+C 调用 `_queryEngine.CancelAsync()` 对应原始组件中的 `AbortController` 取消机制。
---
## 15.3 组件库
对应原始 `../../src/components/` 目录中的 50+ React/Ink 组件。以下列出两个核心组件的 .NET 实现。
### PermissionDialog
```csharp
///
/// 权限对话框 — 用户审批工具使用
/// 对应原始 PermissionRequest 组件
///
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
```csharp
///
/// 同伴精灵渲染 — ASCII art sprite
/// 对应原始 CompanionSprite 组件
///
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 的 `` 将 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` 等)由对应的特性开关控制加载,在各自模块文档中描述。
---
## 参考资料
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
- [UI 与扩展设计 — 特性开关系统](UI与扩展设计-特性开关系统.md)