free-code-dotnet/docs/UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

338 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
/// <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` 的全部交互逻辑,包括消息流渲染、斜杠命令路由和状态订阅。
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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` 等)由对应的特性开关控制加载,在各自模块文档中描述。
---
## 参考资料
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
- [UI 与扩展设计 — 特性开关系统](UI与扩展设计-特性开关系统.md)