338 lines
12 KiB
Markdown
338 lines
12 KiB
Markdown
# 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)
|