Windows?终端模拟器? 嘉豪看到要开始不懂装懂了,接入个CMD不就 OK 了?难的就是接入CMD本身。
我的目标就是实现在WinForm或者WPF下的原生渲染终端画面
一开始我也是这么认为的,拿个richtextbox或者TextBlock不就 OK 了?但是终端远不止输入输出,24 位真彩色、ANSI/VT 转义序列、鼠标支持和真实的控制台交互…
标准流重定向确实能完美跑 pingdir,但是它跑不了真正的 PowerShell 交互模式!
当用纯代码标准重定向运行 powershell.exe 时,Windows 会发现 “哦,调用我的不是原生的黑框框控制台”。于是 Windows 不会给 PTY(伪终端)权限。
失去 PTY 意味着什么?按 Tab 键,它不会补全代码,只会输出一个傻傻的 \t 字符。按键盘的 上、下 方向键,它无法调出历史命令,只会输出 ^[
而且我发现,绝大多数第三方 Windows 终端模拟器都是用的WebView2 + xterm.js。包括曾经著名的FluentTerminal
FluentTerminal
https://github.com/felixse/FluentTerminal/blob/master/FluentTerminal.Client/src/index.ts

但是我对在桌面应用使用 web 技术是深恶痛绝的,所以当务之急是找个轮子,要好的轮子。
微软直到 Windows 10 (1809 版本),才终于在系统底层开放了真正的伪终端 API:ConPTYCreatePseudoConsole),所以能找到用ConPTY的轮子是最好的


ConPTY的连接

这里踩的坑已经单独写过一篇文章了,所以这里提一嘴,Microsoft Terminal 项目里有示例项目教你如何使用ConPTY启动cmd并把输入输出流重定向出来
https://github.com/microsoft/terminal/tree/main/samples/ConPTY
但是注意,这里面是不包括终端渲染的,GUIConsole只是把输入输出流显示到textblock里面


用过的轮子

下面是我用过的轮子,我将告诉你我踩过坑和轮子之间的对比,然后给你推荐一个神级轮子

EasyWindowsTerminalControl

https://github.com/mitchcapper/EasyWindowsTerminalControl
这个仓库头一次点开一看不得了,nuget装上然后一句

1
2

<term:EasyTerminalControl StartupCommandLine="pwsh.exe" />

就能用了,但是我在最新版本 Windows,使用.NET 10实测,窗口完全黑屏,有那个_在闪,但是没内容输出,输入也没反应,没有报错没有异常,毫无任何头绪
疑似是底层的伪控制台(ConPTY / cmd.exe)启动失败或未能成功连接数据流。
如果打开TermExample 源码,会发现那个示例项目里 MainWindow.xaml 和后台代码写了几百上千行,包含各种事件订阅、主题切换、进程拦截、TermPTY 实例的创建和绑定。
作者在设计这个库时的初衷,可能确实是想让它成为一行代码就能跑的傻瓜控件。(作者在 README 原话也是这么写的:*“In theory just add… then * <term:EasyTerminalControl StartupCommandLine="pwsh.exe" /> )。
但是,现实情况是它翻车了。
UI 前端渲染成功了,但背后的 cmd.exe 根本没连上,且把错误吞掉了。
懒得折腾了 换轮子

poderosa

https://github.com/poderosaproject/poderosa
上次 release 发布是在 2025 年 5 月 说明还是有在维护的。
Poderosa 绝对是 .NET 终端模拟器历史上的一座丰碑,它的源码里藏着极其庞大、完整、且经过几十年考验的纯 C# VT100/Xterm 解析引擎,也就是那个能把乱码和控制序列变成带有颜色、光标位置的二维文字网格的核心。
但是要注意,Poderosa 不是一个可以随便拖拽的 DLL 控件,它是一个高度耦合的插件化框架应用。Poderosa 采用了极其古老且严密的 IoC 插件架构。它的 UI 渲染、按键拦截、网络协议全是通过接口动态注入的。
如果我要把他的终端渲染功能拿下来做成窗口组件,我要下载它的源码,找到 Poderosa.TerminalEmulatorPoderosa.Core 文件夹,把里面的 EscapeSequenceProcessorTextBuffer 掏出来,然后自己写一个简单的重定向类把 powershell.exeStandardOutput 塞给这个处理器。

vs-pty.net

https://github.com/microsoft/vs-pty.net
微软出的,Pty.Net 是一个跨平台的 .NET 库,为 .NET 提供惯用的绑定forkpty()
但是你怎么就 63 颗星星啊,怎么nuget | package not found
打开nuget进去看看 所有者已将此软件包从列表中移除。这可能意味着该软件包已弃用、存在安全漏洞或不应再使用。

forkpty 是一个 Unix/Linux 系统函数,用于创建一个伪终端(Pseudo-Terminal,简称 pty)并从中 fork 出一个子进程。它将子进程的标准输入、输出和错误都连接到这个伪终端
那还说啥了,本来也不能在 Windows 用,forkpty是 Linux/Mac 的

XtermSharp

https://github.com/migueldeicaza/XtermSharp
XtermSharp 是一个用于 .NET 的 VT100/Xterm 终端模拟器,其引擎旨在与潜在的前端和后端无关。
终端本身并不负责将数据连接到进程或远程服务器。数据是通过将包含数据的字节数组传递给 “Feed” 方法发送到终端的。
这不就是我要的轮子吗?直接git clone
我操,怎么开局先卸载掉了两个项目。

XtermSharp1

先忽略掉 “此解决方案包含具有漏洞的包”,点开它的核心实现,让我看看它的源码Pty.cs

1
2
3
4
5
6
7
8
9
10
11
[DllImport ("util")]
extern static int forkpty (out int master, IntPtr dataReturn, IntPtr termios, ref UnixWindowSize WinSz);

[DllImport ("libc")]
extern static int execv (string process, string [] args);

[DllImport ("libc")]
extern static int execve (string process, string [] args, string [] env);

[DllImport ("libpty.dylib", EntryPoint="fork_and_exec")]
extern static int HeavyFork (string process, string [] args, string [] env, out int master, UnixWindowSize winSize);

这完全就是在 Mac 上面跑的。但是我觉得这个东西如果能移植到 Windows 成一个窗口控件?
说干就干,开始移植

  1. NStack.RuneSystem.Text.Rune
    NStack.Rune(基于 int)替换为 .NET 10 原生的 System.Text.Runeustring(NStack 字符串)全部替换为 string

  2. Unix Pty.cs → Windows ConPTY 完全重写
    原来的 Pty.cs 是 macOS 专用(forkptylibpty.dylib),Windows 下改用 Win32 ConPTY API(CreatePseudoConsoleResizePseudoConsole

  3. System.Drawing.Point 处理
    SelectionService.cs 用了 System.Drawing.Point,WPF 用 System.Windows.Point,WinForms 保留 System.Drawing.Point,Core 层用自定义结构体隔离

  4. 基于 DrawingVisual + GlyphRun 的做个渲染器

第一次问题,无法输入,中文无法显示。

XtermSharp3

一开始我用的 GlyphRun 方案,而它底层 API 在检测到 Consolas 字体中没有文本包含的该汉字轮廓时,就会直接把它抛弃。
把这个地方的逻辑切换回 FormattedText。这样能显示了
还有在初始加载时没有自动获取 WPF 窗口的焦点树。要在鼠标点击事件中补充了强制对齐获取焦点的代码:Focus()
然后中文正常显示,也能输入东西了

XtermSharp3

OK 勉强能用了,但是所有快捷键都是失效的 (包括F1~F12,和类似ctrl+x的组合键)。还有,回车会触发两次,按了一次回车会有按了两次的效果。
还有按backspace的时候,如果没有连上SSH linux,就会按一次backspace会往前一直删到有空格才停下,如果在ssh会话里面就是会按两次 (也不一定)。
还有鼠标点击也是失效的。还有中文适配也有问题。
还有就是现在不支持彩色输出.
组合键和功能键失效、回车 / 退格双击问题,WPF/Winforms 默认触发的是原始文本输入 (如 \r),所以需要重写整个 KeyDown 的字典树翻译器
让它完全拦截所有的 F1-F12、方向键、PageUp/DownInsert/Delete 以及 Ctrl + A-Z 的组合键,而且将它们原生翻译为真正的 VT100 ANSI 字节控制码输入管道中。
彩色输出问题,利用移位操作提取出前景色、背景色以及加粗 / 斜体 / 反色的 Flag,这样能显示彩色了。

XtermSharp4

但是这个中文间距依旧飞来飞去,那个小白块想在哪闪就在哪闪

XtermSharp5

htop这种的有复杂点的TUI的,鼠标能点的地方点击无反应,而且TUI非常莫名就容易乱。
与此同时我的 claude 还被干限额了,,直接回归古法编程。
这里你看我这么说遇到的 bug,解决 bug 可能觉得很轻松,但是实际难度,你真上手体验就知道了。
放弃了 这东西本来就是给 MAC 写的 我就还是不去逆天而行了
如果你有意接力它的适配,点击下面链接下载我到这一步的移植源代码
https://www.cloudyou.top/files/XtermSharp_win.zip
如果你觉得我修复上面几个 bug 很轻松的话。来,你来下载源码,修复TUI渲染和中文还有鼠标点击,那这个轮子将不逊色于下面这个轮子

绝佳轮子

wt1

我无意发现 Windows Terminal不是纯C实现啊,有C#的代码 难道说

wt6

没错,踏破铁鞋无觅处,得来全不费功夫,我一开始在寻找的窗口控件,微软早就写好了。
OK,把项目git clone下来,用 VS 打开,把所有项目全部重定向到当前已经安装的生成工具和 SDK。
先跑一遍编译,但还是有报错需要v143工具集,但是我电脑只有v145工具集。还有几个项目 vs 没有给我重定向
解决方法也简单,用 vscode 或者别的编辑器而不是 IDE 打开项目,全局搜索替换v143v145,然后就能编译了
注意编译过程,虚拟内存要设置大于 20G,不然会编译失败
先打开powershell按照文档跑一遍全部编译

1
2
3
Import-Module .\tools\OpenConsole.psm1
Set-MsBuildDevEnvironment
Invoke-OpenConsoleBuild

然后 vs 打开 Terminal 下面的 wpf 下面的WpfTerminalTestNetCore,需要的Microsoft.Terminal.Control.dll应该能右键在资源管理器里面显示了
直接生成运行WpfTerminalTestNetCore

wt6

成了!渲染效果和 Windows Terminal 是一样的
让我们把它接入cmdConPTY,先做个ConPtyConnection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
using Microsoft.Terminal.Wpf;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

public class ConPtyConnection : ITerminalConnection, IDisposable
{
private readonly string _commandLine;
private uint _cols;
private uint _rows;

public ConPtyConnection(string commandLine = "cmd.exe", uint cols = 120, uint rows = 30)
{
_commandLine = commandLine;
_cols = cols;
_rows = rows;
}

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CreatePipe(out IntPtr hRead, out IntPtr hWrite,
ref SECURITY_ATTRIBUTES lpPipeAttributes, int nSize);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);

[DllImport("kernel32.dll", SetLastError = true)]
static extern int CreatePseudoConsole(COORD size, IntPtr hInput,
IntPtr hOutput, uint dwFlags, out IntPtr phPC);

[DllImport("kernel32.dll", SetLastError = true)]
static extern int ResizePseudoConsole(IntPtr hPC, COORD size);

[DllImport("kernel32.dll", SetLastError = true)]
static extern void ClosePseudoConsole(IntPtr hPC);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool InitializeProcThreadAttributeList(
IntPtr lpAttributeList, int dwAttributeCount,
int dwFlags, ref IntPtr lpSize);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool UpdateProcThreadAttribute(
IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute,
IntPtr lpValue, IntPtr cbSize,
IntPtr lpPreviousValue, IntPtr lpReturnSize);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool DeleteProcThreadAttributeList(IntPtr lpAttributeList);

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool CreateProcess(
string lpApplicationName, string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags,
IntPtr lpEnvironment, string lpCurrentDirectory,
ref STARTUPINFOEX lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteFile(IntPtr hFile, byte[] lpBuffer,
int nNumberOfBytesToWrite, out int lpNumberOfBytesWritten,
IntPtr lpOverlapped);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadFile(IntPtr hFile, byte[] lpBuffer,
int nNumberOfBytesToRead, out int lpNumberOfBytesRead,
IntPtr lpOverlapped);

[StructLayout(LayoutKind.Sequential)]
struct COORD { public short X, Y; }

[StructLayout(LayoutKind.Sequential)]
struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct STARTUPINFO
{
public int cb;
public string lpReserved, lpDesktop, lpTitle;
public int dwX, dwY, dwXSize, dwYSize;
public int dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}

[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION
{
public IntPtr hProcess, hThread;
public int dwProcessId, dwThreadId;
}

const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
static readonly IntPtr PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = new IntPtr(0x00020016);

public event EventHandler<TerminalOutputEventArgs> TerminalOutput;
IntPtr _hPC = IntPtr.Zero;
IntPtr _hPipeIn = IntPtr.Zero; // 写给子进程
IntPtr _hPipeOut = IntPtr.Zero; // 从子进程读
IntPtr _hPipeInRead = IntPtr.Zero;
IntPtr _hPipeOutWrite = IntPtr.Zero;
PROCESS_INFORMATION _pi;
CancellationTokenSource _cts = new();

public void Start()
{
var sa = new SECURITY_ATTRIBUTES
{
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
bInheritHandle = true
};

// 创建两对管道
CreatePipe(out _hPipeInRead, out _hPipeIn, ref sa, 0);
CreatePipe(out _hPipeOut, out _hPipeOutWrite, ref sa, 0);

// 创建 ConPTY
var size = new COORD { X = (short)_cols, Y = (short)_rows };
CreatePseudoConsole(size, _hPipeInRead, _hPipeOutWrite, 0, out _hPC);

// 设置扩展启动信息(把 ConPTY 塞进去)
var siEx = new STARTUPINFOEX();
siEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
IntPtr size2 = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size2);
siEx.lpAttributeList = Marshal.AllocHGlobal(size2);
InitializeProcThreadAttributeList(siEx.lpAttributeList, 1, 0, ref size2);
UpdateProcThreadAttribute(siEx.lpAttributeList, 0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
_hPC, new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero);

var psa = new SECURITY_ATTRIBUTES { nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>() };
var tsa = new SECURITY_ATTRIBUTES { nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>() };

CreateProcess(null, _commandLine, ref psa, ref tsa, false,
EXTENDED_STARTUPINFO_PRESENT, IntPtr.Zero, null,
ref siEx, out _pi);

DeleteProcThreadAttributeList(siEx.lpAttributeList);
Marshal.FreeHGlobal(siEx.lpAttributeList);

// 启动读取线程,把子进程输出源源不断发给控件
Task.Run(() => ReadLoop(_cts.Token));
}

void ReadLoop(CancellationToken ct)
{
var buf = new byte[4096];
while (!ct.IsCancellationRequested)
{
if (!ReadFile(_hPipeOut, buf, buf.Length, out int bytesRead, IntPtr.Zero) || bytesRead == 0)
break;
var text = System.Text.Encoding.UTF8.GetString(buf, 0, bytesRead);
TerminalOutput?.Invoke(this, new TerminalOutputEventArgs(text));
}
}

public void WriteInput(string data)
{
if (_hPipeIn == IntPtr.Zero) return;
var bytes = System.Text.Encoding.UTF8.GetBytes(data);
WriteFile(_hPipeIn, bytes, bytes.Length, out _, IntPtr.Zero);
}

public void Resize(uint rows, uint columns)
{
_cols = columns;
_rows = rows;
if (_hPC != IntPtr.Zero)
ResizePseudoConsole(_hPC, new COORD { X = (short)columns, Y = (short)rows });
}

public void Close()
{
_cts.Cancel();
if (_hPC != IntPtr.Zero) { ClosePseudoConsole(_hPC); _hPC = IntPtr.Zero; }
if (_hPipeIn != IntPtr.Zero) { CloseHandle(_hPipeIn); _hPipeIn = IntPtr.Zero; }
if (_hPipeOut != IntPtr.Zero) { CloseHandle(_hPipeOut); _hPipeOut = IntPtr.Zero; }
if (_hPipeInRead != IntPtr.Zero) { CloseHandle(_hPipeInRead); _hPipeInRead = IntPtr.Zero; }
if (_hPipeOutWrite != IntPtr.Zero) { CloseHandle(_hPipeOutWrite); _hPipeOutWrite = IntPtr.Zero; }
}

public void Dispose() => Close();
}

然后在WPF中引用它

1
2
3
4
5
6
7
8
9
<Window x:Class="WpfTerminalTestNetCore.CmdWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:term="clr-namespace:Microsoft.Terminal.Wpf;assembly=Microsoft.Terminal.Wpf"
Title="CMD" Height="450" Width="800">
<Grid>
<term:TerminalControl x:Name="Terminal" Focusable="True" />
</Grid>
</Window>

然后在窗口代码里使用它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private ConPtyConnection _connection;

public CmdWindow()
{
InitializeComponent();
Terminal.Loaded += Terminal_Loaded;
}

private void Terminal_Loaded(object sender, RoutedEventArgs e)
{
var theme = new TerminalTheme
{
DefaultBackground = 0x0c0c0c,
DefaultForeground = 0xcccccc,
DefaultSelectionBackground = 0xcccccc,
CursorStyle = CursorStyle.BlinkingBar,
ColorTable = new uint[]
{
0x0C0C0C, 0x1F0FC5, 0x0EA113, 0x009CC1,
0xDA3700, 0x981788, 0xDD963A, 0xCCCCCC,
0x767676, 0x5648E7, 0x0CC616, 0xA5F1F9,
0xFF783B, 0x9E00B4, 0xD6D661, 0xF2F2F2
},
};

_connection = new ConPtyConnection();
var conn = new ConPtyConnection("cmd.exe", 120, 30);

Terminal.Connection = conn;
Terminal.Connection = _connection;
Terminal.SetTheme(theme, "Cascadia Code", 12);
Terminal.Focus();
}

protected override void OnClosed(EventArgs e)
{
_connection?.Close();
base.OnClosed(e);
}

wt7
编译出来运行,这期神了,中文显示,快捷键/组合键,复杂TUI,窗口大小改变重绘,鼠标点击,该有的功能一个都不落,这才是Windows终端模拟器的最佳解决方案
毕竟它接的就是Windows Terminal,Windows Terminal有的功能他都有