アプリの中にコマンドプロンプトのような黒い画面を出して、ボタンを押すと決まったコマンドが送られるようにします。
Windows の ConPTY (擬似コンソール)と xterm.js を使った、その仕組みを解説します。
やること
- NuGet パッケージ
Microsoft.Web.WebView2をプロジェクトに入れる。 - 下のリンクの
Terminalフォルダ (3 ファイル)と、WinForms/TerminalView.csを、プロジェクトに追加する。名前空間は付けていないので、そのまま使えます。 - フォームに
Panelを置いて、数行を足す。足す内容は、「部品をつなぐ」の節にあります。 - ボタンから送る:
_writer?.SendLine("dir /b");
知っておくこと
- 4 つのファイルは、お互いのことを知りません。使いたいものだけを選べます。
- 4 つのファイルの全文は、記事の最後に折りたたんで載せています。
-
WinForms/TerminalView.csは Windows フォーム用です。WinUI 3 用は、リポジトリのWinUI3フォルダにあります。WPF では試していません。 - WinUI 3 のアプリで作る方法は、こちらの記事に書いています: https://qiita.com/Mimumumimu/items/69a1edb0e6af6686f47a
- xterm.js を CDN から読み込むので、インターネットへの接続が要ります。
- MIT ライセンスで自由に使えます。
- 共通の 3 ファイルのフォルダへの直接のリンク: https://github.com/Mimumumimu/CodeSamples/tree/main/ConPtyTerminal/Terminal
- 画面の部品のファイルへの直接のリンク: https://github.com/Mimumumimu/CodeSamples/blob/main/ConPtyTerminal/WinForms/TerminalView.cs
この黒い画面は、コマンドプロンプト・DOS 窓・コンソール・ターミナルなどと呼ばれるものです。
次のようなことで探している人向けの記事です。
- C# のアプリの中にコマンドプロンプトの画面を出したい
- WinForms のフォームに DOS 窓のようなターミナルを埋め込みたい
- 外部のコマンドを動かして出力をそのままリアルタイムに画面に出したい
- 確認を聞いてくる対話型のコマンドをアプリの中で動かしたい
- ボタンを押したら決まったコマンドを送りたい
何のために使うのか
アプリの中でコマンドを動かして、その出力をそのまま見せたいときに使います。
たとえば次のような場面です。
- AI のコマンドラインツールをアプリの中で使いたい。
- ビルドやデプロイのスクリプトをボタンから動かして、途中の出力をその場で見たい。
- 確認を聞いてくるコマンドをアプリの中で動かして、画面の中で答えたい。
- よく使うコマンドをボタンにして並べて、コマンドを覚えなくても使えるようにしたい。
別のウィンドウでコマンドプロンプトを開く必要がなくなります。
画面の中で打つこともできるし、アプリのボタンからコマンドを送ることもできます。
向かない場面
コマンドを 1 回動かして結果の文字だけが欲しいときは、Process.Start で標準出力を読めば足ります。
こちらのほうが簡単です。
ただ、リダイレクトしたパイプは起動された側から見ると端末ではありません。
端末かどうかで動きを変えるコマンドは、色や画面の操作や確認の入力が動かないことがあります。
そういうコマンドを動かしたいときに、ConPTY を使います。
ConPTY は、起動したシェルに「ここは端末です」と見せる擬似的なコンソールを作る仕組みです。
全体の構成
仕組みは 4 つの部品に分かれます。
部品はお互いのことを知りません。
| 部品 | 役割 | 知っていること |
|---|---|---|
PseudoConsole |
ConPTY でシェルを起動して、入力と出力のストリームを渡す | Windows の API だけ |
ShellReader |
シェルの出力を読み続けて、文字列にして知らせる | 出力のストリームだけ |
ShellWriter |
シェルへ文字を送る | 入力のストリームだけ |
TerminalView |
xterm.js で黒い画面を描く | 画面だけ。シェルのことは知らない |
データは次のように流れます。
- シェルの出力 →
ShellReaderが読む → フォームが画面を動かすスレッドに戻す →TerminalViewが描く - 画面で打ったキー →
TerminalViewが知らせる → フォームが受ける →ShellWriterがシェルへ送る
部品の間をフォームがつなぎます。
つなぎ役をフォームに集めると、部品を別の組み合わせで使えます。
前提
- Windows 11 の Windows フォーム アプリ。.NET 8 以降です。動かして確かめたのは .NET 10 です。
- NuGet パッケージ
Microsoft.Web.WebView2。画面を描く xterm.js をブラウザの部品の WebView2 の中で動かすためです。 - インターネットへの接続。xterm.js をインターネットから読み込みます。
WPF のアプリでは試していません。
PseudoConsole・ShellReader・ShellWriter は画面の部品に依存しません。WinUI 3 のアプリでは、この 3 ファイルをそのまま使えました。
WinForms/TerminalView.cs は Windows フォーム用です。WinUI 3 用の TerminalView は、リポジトリの WinUI3 フォルダにあります。
ConPTY でシェルを動かす
PseudoConsole が ConPTY の部分を受け持ちます。
Windows の API (kernel32.dll)を P/Invoke で呼びます。
まず、パイプを 2 本作って、擬似コンソールにつなぎます。
その擬似コンソールを指定してシェルのプロセスを起動します。
シェルは擬似コンソールを普通のコンソールだと思って動き、入力と出力はパイプを通ります。
アプリはパイプの反対側をストリームとして読み書きします。
1. パイプと擬似コンソールを作る
// 1. 入力用と出力用のパイプを 1 本ずつ作る
if (!CreatePipe(out var inputRead, out var inputWrite, IntPtr.Zero, 0)) throw new Win32Exception();
if (!CreatePipe(out var outputRead, out var outputWrite, IntPtr.Zero, 0)) throw new Win32Exception();
// 2. 擬似コンソールを作る。シェルは、入力を inputRead から読み、出力を outputWrite へ書く
var size = new COORD { X = (short)columns, Y = (short)rows };
Marshal.ThrowExceptionForHR(CreatePseudoConsole(size, inputRead, outputWrite, 0, out var console));
// 3. パイプの片側は、擬似コンソールが持つので、こちらでは閉じる
inputRead.Dispose();
outputWrite.Dispose();
パイプは読む側と書く側が対になっています。
入力用はアプリが inputWrite に書いて、シェルが inputRead から読みます。
出力用はシェルが outputWrite に書いて、アプリが outputRead から読みます。
CreatePseudoConsole にはシェル側の 2 つ (inputRead と outputWrite)と画面の大きさ (桁数と行数)を渡します。
渡した 2 つは擬似コンソールが持つので、アプリ側では閉じます。
2. 擬似コンソールを指定してプロセスを起動する
// 4. 「このプロセスを、擬似コンソールにつないで起動する」という指定を作る
var listSize = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref listSize);
var attributeList = Marshal.AllocHGlobal(listSize);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref listSize)) throw new Win32Exception();
if (!UpdateProcThreadAttribute(attributeList, 0, (IntPtr)ProcThreadAttributePseudoConsole,
console, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception();
var startupInfo = new STARTUPINFOEX { lpAttributeList = attributeList };
startupInfo.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
// このアプリの標準入出力を、シェルに引き継がせない (引き継ぐと、画面に出る内容が擬似コンソールに届かないことがある)
startupInfo.StartupInfo.dwFlags = StartfUseStdHandles;
// 5. プロセスを起動する。コマンドラインは、書き換えられることがあるので、StringBuilder で渡す
var commandLineBuffer = new StringBuilder(commandLine);
if (!CreateProcessW(null, commandLineBuffer, IntPtr.Zero, IntPtr.Zero, false,
ExtendedStartupInfoPresent, IntPtr.Zero, null, ref startupInfo, out var processInfo))
{
throw new Win32Exception();
}
プロセスを擬似コンソールにつなぐには「属性リスト」を使います。
InitializeProcThreadAttributeList は 2 回呼びます。1 回目は大きさを聞くため、2 回目は作るためです。
UpdateProcThreadAttribute で、属性 ProcThreadAttributePseudoConsole に擬似コンソールを指定します。
その属性リストを STARTUPINFOEX に入れて CreateProcessW に渡すと、プロセスが擬似コンソールにつながって起動します。
dwFlags = StartfUseStdHandles は、このアプリの標準入出力をシェルに引き継がせない指定です。
これが無いと、このアプリの標準出力がリダイレクトされているとき、たとえば別のプログラムから起動したときに、シェルの出力が擬似コンソールに届かないことがあります。
3. ストリームを返す
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
CloseHandle(processInfo.hThread);
return new PseudoConsole(
console,
processInfo.hProcess,
new FileStream(inputWrite, FileAccess.Write, 0),
new FileStream(outputRead, FileAccess.Read, 0));
アプリが使うのは inputWrite と outputRead の 2 つです。
どちらも FileStream にして Input と Output として渡します。
使い方は、2 行です。
using var console = PseudoConsole.Start("cmd.exe", 100, 30); // シェルを起動する。100 桁、30 行
// console.Input にキー入力を書く。console.Output から画面の内容を読む
大きさの変更と閉じ方
画面の大きさが変わったら、ResizePseudoConsole で擬似コンソールに伝えます。
閉じるときは、ClosePseudoConsole を呼びます。
public void Close()
{
if (_closed) return;
_closed = true;
Input.Dispose();
// 出力を読み続けていないと、閉じる処理が戻らないことがあるので、別のスレッドで閉じて、3 秒だけ待つ
var console = _console;
Task.Run(() => ClosePseudoConsole(console)).Wait(TimeSpan.FromSeconds(3));
}
ClosePseudoConsole は、出力を読み続けていないと戻らないことがあります。
そのため、別のスレッドで呼んで、3 秒だけ待ちます。
呼ぶ側は、出力の読み取りを止めずに Close を呼んで、読み取りが終わるのを待ってから Dispose します。
出力を読む
ShellReader はシェルの出力を読み続けて、文字列にして知らせます。
画面のことは知りません。
private void ReadLoop()
{
var buffer = new byte[16 * 1024];
var chars = new char[Encoding.UTF8.GetMaxCharCount(buffer.Length)];
// 日本語などが、読み取りの切れ目で分かれても壊れないよう、デコーダーを使い回す
var decoder = Encoding.UTF8.GetDecoder();
try
{
int read;
while ((read = _output.Read(buffer)) > 0)
{
var count = decoder.GetChars(buffer, 0, read, chars, 0);
if (count > 0)
{
OutputReceived?.Invoke(new string(chars, 0, count));
}
}
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// シェルが終わって、パイプが閉じられた
}
}
読み取りは別のスレッドで、シェルが終わるまで続けます。
読んだバイト列は OutputReceived イベントで文字列として知らせます。
日本語は UTF-8 では 1 文字が 3 バイトです。
読み取りの切れ目が文字の途中に当たると、文字が壊れます。
毎回 Encoding.UTF8.GetString を呼ぶのではなく GetDecoder() で作ったデコーダーを使い回すと、続きの読み取りで文字を補って正しく戻します。
入力を送る
ShellWriter はシェルへ文字を送ります。
出力のことは知りません。
public void Send(string text)
{
if (string.IsNullOrEmpty(text)) return;
try
{
_input.Write(Encoding.UTF8.GetBytes(text));
_input.Flush();
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// シェルが終わったあとの入力は、捨てる
}
}
public void SendLine(string text) => Send(text + "\r");
シェルには、キー入力として届きます。
SendLine("dir") は dir を送ってから Enter を押したことにします。
Enter は文字 \r を送ると押したことになります。
画面を描く
TerminalView は xterm.js で黒い画面を描きます。
文字を渡されたら描き、キーが打たれたら知らせるだけで、シェルのことは知りません。
シェルの出力には、色や画面の消去の命令が文字として混ざっています。
その命令を解釈して描くのが xterm.js です。
自分で解釈するのは大変なので、xterm.js に任せています。
xterm.js はブラウザの上で動く JavaScript のライブラリなので、WebView2 の中で動かします。
C# と JavaScript のやり取りは JSON のメッセージです。
/// <summary>画面に文字を描く。画面を動かすスレッドから呼ぶ</summary>
public void Write(string text)
{
var json = JsonSerializer.Serialize(new { type = "output", data = text });
_webView.CoreWebView2?.PostWebMessageAsJson(json);
}
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
using var message = JsonDocument.Parse(e.WebMessageAsJson);
var root = message.RootElement;
switch (root.GetProperty("type").GetString())
{
case "ready":
Ready?.Invoke(root.GetProperty("cols").GetInt32(), root.GetProperty("rows").GetInt32());
break;
case "input":
InputTyped?.Invoke(root.GetProperty("data").GetString() ?? "");
break;
case "resize":
Resized?.Invoke(root.GetProperty("cols").GetInt32(), root.GetProperty("rows").GetInt32());
break;
}
}
output で C# から JavaScript へ描く文字を渡します。
input と resize と ready で、JavaScript から C# へ、打たれたキーと大きさと準備ができたことを伝えます。
JavaScript の側は、次の部分です。
// 打ったキーを、C# へ送る
term.onData(data => host.postMessage({ type: "input", data }));
// 大きさが変わったら、C# へ伝える
term.onResize(size => host.postMessage({ type: "resize", cols: size.cols, rows: size.rows }));
window.addEventListener("resize", () => fit.fit());
// C# から届いた文字を、画面に描く
host.addEventListener("message", e => {
if (e.data.type === "output") term.write(e.data.data);
});
// 準備ができたことを、C# へ伝える
host.postMessage({ type: "ready", cols: term.cols, rows: term.rows });
xterm.js の読み込み
xterm.js はインターネットの CDN から読み込みます。
読み込む先が書き換えられると、打たれたキーを受け取る側の JavaScript がシェルにコマンドを送れてしまいます。
そこで、バージョンを固定して、integrity でファイルの中身が想定どおりかを確かめています。
integrity の値は固定したファイルの SHA-384 のハッシュです。バージョンを上げるときは値も計算し直します。
インターネットに接続できない環境や通信が制限された環境では、CDN から読み込めず黒い画面が出ません。
実際のアプリに入れるときは、xterm.js のファイルをアプリに同梱する形にするほうが安心です。
このサンプルは試しやすいように CDN にしてあります。
部品をつなぐ
フォームが 4 つの部品をつなぎます。
部品がお互いを知らないので、つなぎ方はフォームの中にまとまります。
画面は Panel の中に入れます。
フォームに直接足すと、黒い画面がフォーム全体の大きさになって、すでにあるボタンや操作パネルと重なります。
試したところ、上に操作パネルがあるフォームで直接足すと、黒い画面がフォームの上端から下端までの大きさになりました。
フィールドとイベントの結びつけです。
ここではフォームの名前を Form1、Panel の名前を terminalHost としています。
private readonly TerminalView _terminal = new() { Dock = DockStyle.Fill };
private PseudoConsole? _console;
private ShellReader? _reader;
private ShellWriter? _writer;
コンストラクターの InitializeComponent(); のあとに次を足します。
terminalHost.Controls.Add(_terminal); // 黒い画面を、Panel の中に入れる
_terminal.Ready += OnTerminalReady;
_terminal.InputTyped += text => _writer?.Send(text); // 画面で打ったキーを、シェルへ
_terminal.Resized += (columns, rows) => _console?.Resize(columns, rows); // 画面の大きさを、シェルへ
FormClosing += (_, _) => StopShell();
画面の準備ができたら、シェルを起動して読み込みと送信の部品をつなぎます。
終わるときはシェルを終わらせて後片付けをします。
// 画面の準備ができたら、シェルを起動して、読み込みと送信の部品をつなぐ
private void OnTerminalReady(int columns, int rows)
{
_console = PseudoConsole.Start("cmd.exe", columns, rows);
_reader = new ShellReader(_console.Output);
// 読み込みは別のスレッドで動くので、画面に描くときは、BeginInvoke で画面のスレッドへ戻す
_reader.OutputReceived += text => BeginInvoke(() => _terminal.Write(text));
_reader.Start();
_writer = new ShellWriter(_console.Input);
}
// シェルを終わらせて、後片付けをする
private void StopShell()
{
_console?.Close();
_reader?.Wait(TimeSpan.FromSeconds(1));
_console?.Dispose();
_console = null;
}
読み込みは別のスレッドで動きます。
黒い画面に描くのは画面を動かすスレッドの仕事なので、BeginInvoke で戻しています。
"cmd.exe" を "powershell.exe" に変えると、PowerShell を起動できます。
ボタンからコマンドを送る
既存のボタンの Click の中に 1 行書くだけです。
private void SomeExistingButton_Click(object? sender, EventArgs e)
{
_writer?.SendLine("dir /b");
}
SendLine は文字を送って Enter を押します。
Enter を押したくないときは Send を使います。
アプリを起動すると、フォームに黒い画面が出て cmd.exe が動きます。
ボタンを押すと dir /b が送られて、結果が黒い画面に出ます。
黒い画面の中をクリックして、キーボードで打つこともできます。
入力欄と送信ボタンまで付いた完成形の見本は、リポジトリの WinForms/MainForm.cs にあります。
つまずきやすいところ
「スレッド モードを設定してから変更することはできません」と出る
起動したときに次のエラーが出ます。
スレッド モードを設定してから変更することはできません。(0x80010106 (RPC_E_CHANGED_MODE))
アプリの Main に、[STAThread] が付いていません。
WebView2 は画面を動かすスレッドが STA である必要があります。
「Windows フォーム アプリ」のテンプレートで作ったアプリには、最初から付いています。
Main の上の行に [STAThread] があるかを確かめてください。
黒い画面に何も出ない
xterm.js をインターネットから読み込めていません。
インターネットに接続できているかを確かめてください。
シェルの出力が黒い画面に出ない
PseudoConsole の startupInfo.StartupInfo.dwFlags = StartfUseStdHandles; の行が無いか消えています。
理由は、上の「擬似コンソールを指定してプロセスを起動する」に書いたとおりです。
終了するときに固まる
擬似コンソールを閉じる ClosePseudoConsole は、出力を読み続けていないと戻らないことがあります。
Close では別のスレッドで閉じて、3 秒だけ待っています。
StopShell も、読み込みを止めずに Close を呼んで、読み込みが終わるのを待ってから Dispose する順にしています。
ビルドのときに警告が出る
警告が 11 件ほど出ることがあります。
WebView2 のパッケージに含まれる WPF 用の部品が出すもので、動作には関係ありません。
消したいときは、プロジェクトの .csproj を開いて、<PropertyGroup> の中に次の 1 行を足します。
<NoWarn>$(NoWarn);MSB3277</NoWarn>
ビルドに失敗する
using が足りないというエラーが出たら、足したファイルの先頭の using を消していないかを確かめてください。
プロジェクトで暗黙の using が無効でも、4 ファイルはそのままビルドできるようにしてあります。
Nullable が無効のプロジェクトでは、PseudoConsole? のような ? に警告が出ます。動作には関係ありません。
部品を別の形で使う
部品はお互いのことを知らないので、別の組み合わせでも使えます。
| やりたいこと | 使う部品 |
|---|---|
| 画面なしでシェルにコマンドを送って結果を読む |
PseudoConsole・ShellReader・ShellWriter
|
| 画面なしでシェルにコマンドを送るだけ |
PseudoConsole・ShellWriter
|
| 黒い画面を自分のフォームに置く | 上のすべてに TerminalView を足す |
全部のコード
サンプルの 4 つのファイルです。
先頭に必要な using と #nullable enable を書いてあるので、プロジェクトの設定に関わらずそのままビルドできます。
名前空間は付けていません。付けたいときは、4 つとも同じ名前空間にしてください。
Terminal/PseudoConsole.cs
#nullable enable
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
/// <summary>ConPTY (擬似コンソール) でプロセスを動かす。</summary>
public sealed class PseudoConsole : IDisposable
{
private readonly IntPtr _console;
private readonly IntPtr _process;
private bool _closed;
/// <summary>シェルへ書き込むストリーム (キー入力)</summary>
public Stream Input { get; }
/// <summary>シェルから読み取るストリーム (画面に出る内容)</summary>
public Stream Output { get; }
private PseudoConsole(IntPtr console, IntPtr process, Stream input, Stream output)
{
_console = console;
_process = process;
Input = input;
Output = output;
}
/// <summary>擬似コンソールを作って、コマンドを動かす</summary>
public static PseudoConsole Start(string commandLine, int columns, int rows)
{
// 1. 入力用と出力用のパイプを 1 本ずつ作る
if (!CreatePipe(out var inputRead, out var inputWrite, IntPtr.Zero, 0)) throw new Win32Exception();
if (!CreatePipe(out var outputRead, out var outputWrite, IntPtr.Zero, 0)) throw new Win32Exception();
// 2. 擬似コンソールを作る。シェルは、入力を inputRead から読み、出力を outputWrite へ書く
var size = new COORD { X = (short)columns, Y = (short)rows };
Marshal.ThrowExceptionForHR(CreatePseudoConsole(size, inputRead, outputWrite, 0, out var console));
// 3. パイプの片側は、擬似コンソールが持つので、こちらでは閉じる
inputRead.Dispose();
outputWrite.Dispose();
// 4. 「このプロセスを、擬似コンソールにつないで起動する」という指定を作る
var listSize = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref listSize);
var attributeList = Marshal.AllocHGlobal(listSize);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref listSize)) throw new Win32Exception();
if (!UpdateProcThreadAttribute(attributeList, 0, (IntPtr)ProcThreadAttributePseudoConsole,
console, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception();
var startupInfo = new STARTUPINFOEX { lpAttributeList = attributeList };
startupInfo.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
// このアプリの標準入出力を、シェルに引き継がせない (引き継ぐと、画面に出る内容が擬似コンソールに届かないことがある)
startupInfo.StartupInfo.dwFlags = StartfUseStdHandles;
// 5. プロセスを起動する。コマンドラインは、書き換えられることがあるので、StringBuilder で渡す
var commandLineBuffer = new StringBuilder(commandLine);
if (!CreateProcessW(null, commandLineBuffer, IntPtr.Zero, IntPtr.Zero, false,
ExtendedStartupInfoPresent, IntPtr.Zero, null, ref startupInfo, out var processInfo))
{
throw new Win32Exception();
}
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
CloseHandle(processInfo.hThread);
return new PseudoConsole(
console,
processInfo.hProcess,
new FileStream(inputWrite, FileAccess.Write, 0),
new FileStream(outputRead, FileAccess.Read, 0));
}
/// <summary>シェルが終わるまで待つ</summary>
public void WaitForExit() => WaitForSingleObject(_process, 0xFFFFFFFF);
/// <summary>画面の大きさ (桁数と行数) を変える</summary>
public void Resize(int columns, int rows)
{
if (!_closed)
{
ResizePseudoConsole(_console, new COORD { X = (short)columns, Y = (short)rows });
}
}
/// <summary>擬似コンソールを閉じる。出力を読み続けたまま呼ぶこと</summary>
public void Close()
{
if (_closed) return;
_closed = true;
Input.Dispose();
// 出力を読み続けていないと、閉じる処理が戻らないことがあるので、別のスレッドで閉じて、3 秒だけ待つ
var console = _console;
Task.Run(() => ClosePseudoConsole(console)).Wait(TimeSpan.FromSeconds(3));
}
public void Dispose()
{
Close();
Output.Dispose();
CloseHandle(_process);
}
private const uint ExtendedStartupInfoPresent = 0x00080000;
private const int StartfUseStdHandles = 0x00000100;
private const int ProcThreadAttributePseudoConsole = 0x00020016;
[StructLayout(LayoutKind.Sequential)]
private struct COORD
{
public short X;
public short Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFO
{
public int cb;
public IntPtr lpReserved;
public IntPtr lpDesktop;
public IntPtr lpTitle;
public int dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CreatePipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe, IntPtr attributes, uint size);
[DllImport("kernel32.dll")]
private static extern int CreatePseudoConsole(COORD size, SafeFileHandle input, SafeFileHandle output, uint flags, out IntPtr console);
[DllImport("kernel32.dll")]
private static extern int ResizePseudoConsole(IntPtr console, COORD size);
[DllImport("kernel32.dll")]
private static extern void ClosePseudoConsole(IntPtr console);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size, IntPtr previous, IntPtr returned);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(IntPtr list);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes,
bool inheritHandles, uint creationFlags, IntPtr environment, string? currentDirectory, ref STARTUPINFOEX startupInfo, out PROCESS_INFORMATION processInfo);
[DllImport("kernel32.dll")]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr handle);
}
Terminal/ShellReader.cs
#nullable enable
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
/// <summary>
/// シェルの出力を読み続けて、文字列にして知らせる。
/// 画面のことも、入力のことも知らない。出力のストリームだけを受け取る。
/// </summary>
public sealed class ShellReader
{
private readonly Stream _output;
private Task? _task;
/// <summary>シェルの出力を受け取った。画面を動かすスレッドとは別のスレッドから呼ばれる</summary>
public event Action<string>? OutputReceived;
/// <param name="output">シェルの出力のストリーム (<see cref="PseudoConsole.Output"/>)</param>
public ShellReader(Stream output) => _output = output;
/// <summary>読み取りを始める</summary>
public void Start() => _task = Task.Run(ReadLoop);
/// <summary>読み取りが終わるのを、最大で指定した時間だけ待つ</summary>
public void Wait(TimeSpan timeout) => _task?.Wait(timeout);
private void ReadLoop()
{
var buffer = new byte[16 * 1024];
var chars = new char[Encoding.UTF8.GetMaxCharCount(buffer.Length)];
// 日本語などが、読み取りの切れ目で分かれても壊れないよう、デコーダーを使い回す
var decoder = Encoding.UTF8.GetDecoder();
try
{
int read;
while ((read = _output.Read(buffer)) > 0)
{
var count = decoder.GetChars(buffer, 0, read, chars, 0);
if (count > 0)
{
OutputReceived?.Invoke(new string(chars, 0, count));
}
}
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// シェルが終わって、パイプが閉じられた
}
}
}
Terminal/ShellWriter.cs
#nullable enable
using System;
using System.IO;
using System.Text;
/// <summary>
/// シェルへ、文字を送る。シェルには、キー入力として届く。
/// 画面のことも、出力のことも知らない。入力のストリームだけを受け取る。
/// </summary>
public sealed class ShellWriter
{
private readonly Stream _input;
/// <param name="input">シェルの入力のストリーム (<see cref="PseudoConsole.Input"/>)</param>
public ShellWriter(Stream input) => _input = input;
/// <summary>文字をそのまま送る</summary>
public void Send(string text)
{
if (string.IsNullOrEmpty(text)) return;
try
{
_input.Write(Encoding.UTF8.GetBytes(text));
_input.Flush();
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// シェルが終わったあとの入力は、捨てる
}
}
/// <summary>文字を送って、Enter を押す</summary>
public void SendLine(string text) => Send(text + "\r");
}
WinForms/TerminalView.cs
#nullable enable
using System;
using System.Text.Json;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
/// <summary>
/// xterm.js で、ターミナルの画面を描く部品。
/// シェルのことは知らない。文字を渡されたら描き、キーが打たれたら知らせるだけ。
/// </summary>
public sealed class TerminalView : UserControl
{
private readonly WebView2 _webView = new() { Dock = DockStyle.Fill };
/// <summary>画面の準備ができた (桁数, 行数)</summary>
public event Action<int, int>? Ready;
/// <summary>画面の中で、キーが打たれた (打たれた文字)</summary>
public event Action<string>? InputTyped;
/// <summary>画面の大きさが変わった (桁数, 行数)</summary>
public event Action<int, int>? Resized;
public TerminalView()
{
Controls.Add(_webView);
Load += async (_, _) =>
{
await _webView.EnsureCoreWebView2Async();
_webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
_webView.NavigateToString(Html);
};
}
/// <summary>画面に文字を描く。画面を動かすスレッドから呼ぶ</summary>
public void Write(string text)
{
var json = JsonSerializer.Serialize(new { type = "output", data = text });
_webView.CoreWebView2?.PostWebMessageAsJson(json);
}
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
using var message = JsonDocument.Parse(e.WebMessageAsJson);
var root = message.RootElement;
switch (root.GetProperty("type").GetString())
{
case "ready":
Ready?.Invoke(root.GetProperty("cols").GetInt32(), root.GetProperty("rows").GetInt32());
break;
case "input":
InputTyped?.Invoke(root.GetProperty("data").GetString() ?? "");
break;
case "resize":
Resized?.Invoke(root.GetProperty("cols").GetInt32(), root.GetProperty("rows").GetInt32());
break;
}
}
/// <summary>画面の HTML。xterm.js を、CDN から読み込む</summary>
private const string Html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css"
integrity="sha384-8Xk9wy/gzEDUKrXtrmCFa2bBuK3BpjpDuL/p0SeKQX19Khl/M+lHOgD/CyYf7efP" crossorigin="anonymous">
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
#terminal { height: 100%; }
</style>
</head>
<body>
<div id="terminal"></div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"
integrity="sha384-M169f14mRZOXm3hD/v2Ti0ThIT/RnAQagXA9nlE15yHAtrW19gdePJh/HaTzUOe/" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"
integrity="sha384-iF+jqbuti4XlB64clWgFWYEscb+UnSRv3VgVikGYZu+otNFnSHr7y7NcKfBnGizn" crossorigin="anonymous"></script>
<script>
const host = window.chrome.webview;
const term = new Terminal({ fontSize: 14 });
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("terminal"));
fit.fit();
// 打ったキーを、C# へ送る
term.onData(data => host.postMessage({ type: "input", data }));
// 大きさが変わったら、C# へ伝える
term.onResize(size => host.postMessage({ type: "resize", cols: size.cols, rows: size.rows }));
window.addEventListener("resize", () => fit.fit());
// C# から届いた文字を、画面に描く
host.addEventListener("message", e => {
if (e.data.type === "output") term.write(e.data.data);
});
// 準備ができたことを、C# へ伝える
host.postMessage({ type: "ready", cols: term.cols, rows: term.rows });
</script>
</body>
</html>
""";
}
この仕組みが動いているところ
この仕組みは、MmmTool という Windows 11 のタスクトレイに常駐するツールで、Claude Code や Kiro を動かす画面に使っています。
MmmTool では、決まったコマンドをボタンで送る使い方をしていて、PowerShell と cmd と WSL を同じ仕組みで動かしています。
このサンプルのボタンからコマンドを送る形が、その元になっています。
MmmTool は xterm.js をアプリに同梱しています。
xterm.js は MIT ライセンスです。
このサンプルのコードも MIT ライセンスで自由に使えます。
AI と一緒に作りました。どう動くかは自分で決めて、気に入らないところは指示して直しています。
