C#实现控制台多区域输出

前言

近一年以来,AI Agent的发展速度非常快。

如果经常使用一些Agent CLI工具,例如 Claude Code、Gemini CLI、OpenCode 等产品,会发现它们有一个共同特点:

虽然运行在终端之中,但已经完全不是传统命令行程序的样子。

在执行任务过程中,它们通常会同时展示:

  • Agent执行状态
  • 思考过程
  • 文件变更信息
  • Token统计
  • 系统日志
  • 工具调用结果

整个终端界面被划分成多个独立区域,并且每个区域都在实时刷新。

例如下面这种布局:

text 复制代码
┌────────────────────┬────────────────────┐
│ Agent状态          │ Token统计          │
│                    │                    │
├────────────────────┴────────────────────┤
│                                         │
│              执行过程区域               │
│                                         │
├─────────────────────────────────────────┤
│               系统日志                  │
└─────────────────────────────────────────┘

上次在微信群里看到黑洞大佬在做类似的Agent CLI谈到过控制台多区域输出的问题,我当时比较好奇:

C# 原生 Console 是如何实现多区域动态界面的呢?

经过一番研究之后发现,实现原理并没有想象中复杂。

本文通过一个简单示例,介绍如何利用 C# Console 实现:

  • 多区域布局
  • 动态内容刷新
  • 滚动日志窗口
  • 多线程安全输出
  • 优雅退出机制

Console为什么能够实现多区域输出

大多数情况下,我们使用 Console 都是这样:

csharp 复制代码
Console.WriteLine("任务开始");

for (int i = 0; i < 10; i++)
{
    Console.WriteLine($"执行进度:{i}");
}

Console.WriteLine("任务结束");

输出结果如下:

text 复制代码
任务开始
执行进度:0
执行进度:1
执行进度:2
...
任务结束

看起来控制台只能从上往下不断输出内容。

实际上 Console 还提供了一组非常重要的API:

csharp 复制代码
Console.SetCursorPosition(x, y);

它允许程序直接控制光标位置。

例如:

csharp 复制代码
Console.SetCursorPosition(10, 5);
Console.Write("Hello");

程序会直接在指定坐标位置输出内容。

也就是说:

text 复制代码
Console ≠ 输出流

而更像是:

text 复制代码
Console = 字符画布

只要能够控制坐标位置,就能够实现区域划分与动态刷新。

这也是所有终端UI框架最基础的实现原理。

实现控制台布局

首先需要将控制台划分成多个区域。

本示例将控制台分成三个部分:

  • 左上区域显示系统时间
  • 右上区域显示任务进度
  • 下半区域显示运行日志

布局绘制代码如下:

csharp 复制代码
static void DrawLayout()
{
    int width = Console.WindowWidth;
    int height = Console.WindowHeight;

    int midX = width / 2;
    int midY = height / 2;

    for (int y = 0; y < midY; y++)
    {
        SafeWrite(midX, y, "│");
    }

    for (int x = 0; x < width - 1; x++)
    {
        SafeWrite(x, midY, "─");
    }

    SafeWrite(2, 0, "[ 系统时间 ]");
    SafeWrite(midX + 2, 0, "[ 任务进度 ]");
    SafeWrite(2, midY + 1, "[ 运行日志 (滚动) ]");
}

运行之后界面如下:

整个布局没有使用任何第三方组件。

本质上就是利用字符绘制边框。

实现系统时间区域

布局完成之后,实现左上角的时间显示区域。

代码如下:

csharp 复制代码
static void UpdateRegion_Clock()
{
    while (_isRunning)
    {
        SafeWrite(
            2,
            2,
            DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));

        Thread.Sleep(1000);
    }
}

运行效果:

text 复制代码
2026-05-21 16:30:25

由于始终输出到同一个位置,因此每次刷新都会覆盖之前的内容。

从而形成动态更新时间的效果。

实现任务进度区域

右上角区域用于模拟任务进度。

实现代码如下:

csharp 复制代码
static void UpdateRegion_Progress()
{
    int progress = 0;
    int midX = Console.WindowWidth / 2;

    while (_isRunning)
    {
        progress = (progress + 1) % 101;

        int barWidth = 20;

        int filled =
            (int)(barWidth * (progress / 100.0));

        string bar =
            "[" +
            new string('█', filled) +
            new string(' ', barWidth - filled) +
            $"] {progress}%";

        SafeWrite(midX + 2, 2, bar);

        Thread.Sleep(50);
    }
}

运行效果如下:

text 复制代码
[██████████████      ] 72%

这种实现方式和很多安装程序、下载工具中的进度条实现原理基本一致。

实现滚动日志窗口

日志区域是整个示例最核心的部分。

如果简单使用:

csharp 复制代码
Console.WriteLine();

日志会不断向下滚动。

很快就会占满整个控制台。

因此需要一个固定区域用于展示日志内容。

首先定义日志队列:

csharp 复制代码
private static readonly Queue<LogEntry>
    _logQueue = new Queue<LogEntry>();

新增日志:

csharp 复制代码
_logQueue.Enqueue(
    new LogEntry
    {
        Text = newLog,
        Color = color
    });

超过最大显示行数时移除旧日志:

csharp 复制代码
while (_logQueue.Count > _maxLogLines)
{
    _logQueue.Dequeue();
}

然后重新绘制日志区域:

csharp 复制代码
foreach (var log in _logQueue)
{
    Console.SetCursorPosition(2, currentY);
    Console.ForegroundColor = log.Color;
    Console.Write(log.Text);

    currentY++;
}

运行效果如下:

text 复制代码
15:32:11.212 [INFO ] 初始化完成
15:32:11.518 [INFO ] 加载配置文件
15:32:11.802 [DEBUG] 创建任务
15:32:12.015 [WARN ] Token接近阈值
15:32:12.381 [ERROR] 请求超时
15:32:12.912 [INFO ] 自动重试成功

同时根据日志等级设置不同颜色:

csharp 复制代码
static ConsoleColor GetLogLevelColor(string level)
{
    switch (level)
    {
        case "ERROR":
            return ConsoleColor.Red;
        case "WARN":
            return ConsoleColor.Yellow;
        case "DEBUG":
            return ConsoleColor.DarkGray;
        default:
            return ConsoleColor.Green;
    }
}

这样整个日志区域看起来就更接近真实系统运行效果。

多线程下的控制台竞争问题

到这里,一个新的问题出现了。

当前程序存在三个后台线程:

  • 时间刷新线程
  • 进度刷新线程
  • 日志刷新线程

这些线程都会同时操作控制台。

例如:

csharp 复制代码
Console.SetCursorPosition(x, y);
Console.Write(text);

如果多个线程同时执行,很容易出现输出错乱。

因此需要统一加锁。

首先定义控制台锁对象:

csharp 复制代码
private static readonly object _consoleLock =
    new object();

然后封装安全输出方法:

csharp 复制代码
static void SafeWrite(
    int x,
    int y,
    string text)
{
    lock (_consoleLock)
    {
        Console.SetCursorPosition(x, y);
        Console.Write(text);
    }
}

后续所有区域输出都通过该方法完成。

这样能够保证同一时刻只有一个线程修改控制台状态。

避免多个线程抢占光标位置导致界面错乱。

优雅退出机制

很多控制台程序都会忽略退出逻辑。

例如按下 Ctrl+C 之后:

text 复制代码
程序被强制终止

此时可能出现:

  • 光标未恢复
  • 输出颜色异常
  • 界面残留

因此示例中特意处理了退出流程。

首先监听 Ctrl+C:

csharp 复制代码
Console.CancelKeyPress += (sender, e) =>
{
    e.Cancel = true;
    _isRunning = false;
};

然后所有后台线程统一监听运行状态:

csharp 复制代码
while (_isRunning)
{
}

退出时执行清理操作:

csharp 复制代码
static void CleanupConsole()
{
    Thread.Sleep(200);

    Console.ResetColor();
    Console.CursorVisible = true;
    Console.Clear();

    Console.SetCursorPosition(0, 0);

    Console.WriteLine("程序已优雅退出。");
}

这样无论通过普通按键还是 Ctrl+C 退出,控制台都能够恢复到正常状态。

完整程序启动逻辑

整个程序的启动流程并不复杂。

主函数如下:

csharp 复制代码
static void Main(string[] args)
{
    Console.CursorVisible = false;

    Console.Clear();
    DrawLayout();

    Task.Run(UpdateRegion_Clock);
    Task.Run(UpdateRegion_Progress);
    Task.Run(UpdateRegion_Logs);

    while (_isRunning)
    {
        if (Console.KeyAvailable)
        {
            Console.ReadKey(true);
            _isRunning = false;
        }

        Thread.Sleep(100);
    }

    CleanupConsole();
}

程序启动之后:

  1. 初始化控制台
  2. 绘制布局
  3. 启动多个后台任务
  4. 实时刷新各个区域
  5. 接收退出信号
  6. 执行清理工作

整体结构非常简单清晰。

由于篇幅有限,未能展示完整代码示例。博主已将源码实例上传至GitHub,有兴趣的同学可自行翻阅

https://github.com/softlgl/ConsoleMultiRegion

为什么成熟框架做得更好

虽然原生 Console 可以实现多区域动态界面,但如果真正开发 Agent CLI 产品,通常不会直接操作坐标。

目前比较流行的终端UI框架包括:

  • Spectre.Console
  • Terminal.Gui
  • Ink

这些框架已经帮开发者处理好了:

  • 布局系统
  • 表格组件
  • 动态重绘
  • 窗口缩放适配

例如在 Spectre.Console 中,一个布局可能只需要几行代码:

csharp 复制代码
var layout = new Layout()
    .SplitRows(
        new Layout("Header"),
        new Layout("Body"),
        new Layout("Footer"));

开发效率和清晰程度远高于手动维护区域坐标。

不过从实现原理来看,它们应该最终仍然离不开:

csharp 复制代码
Console.SetCursorPosition()

以及区域重绘机制。

理解这些基础能力之后,再去阅读相关框架源码,会更容易理解其设计思想。

总结

随着AI Agent的发展,越来越多工具开始采用CLI作为交互入口。

现代终端程序也逐渐从传统的:

text 复制代码
输入 → 输出

演变为:

text 复制代码
布局 → 交互 → 状态管理 → 动态刷新

本文通过一个简单示例演示了如何使用 C# Console 实现:

  • 多区域布局
  • 实时时钟
  • 动态进度条
  • 滚动日志窗口
  • 多线程安全输出
  • 优雅退出机制

从实现角度来看,其核心能力并不复杂。

本质上就是利用:

csharp 复制代码
Console.SetCursorPosition()

配合:

text 复制代码
线程同步
区域划分
状态管理
动态重绘

构建出一个具备实时交互能力的终端界面。

我个人一般比较喜欢研究基础的实现,因为很多看起来十分复杂的效果,最后追溯到底层实现,其实都离不开这些最基础的能力,也就是咱们常说的底层逻辑。

👇欢迎扫码关注我的公众号👇