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

12 KiB
Raw Blame History

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 组件中最关键的两个(PermissionDialogCompanionSpriteView)映射为 Terminal.Gui 的 DialogView 子类。


15.1 应用启动

TerminalApp 是整个 UI 的入口,对应原始 cli.tsxREPL.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 StatusBarTerminal.Gui 内置) 底部状态栏
Spinner Label(动态文本) 思考中指示器
MessageList ListView 消息历史列表
PromptInput TextView 多行输入框

其余 43+ 组件(FileDiffTokenBudgetHistoryPicker 等)由对应的特性开关控制加载,在各自模块文档中描述。


参考资料