アプリの中にコマンドプロンプトのような黒い画面を出して、ボタンを押すと決まったコマンドが送られるようにします。
WinUI 3 のアプリで、Windows の ConPTY (擬似コンソール)と xterm.js を使って作ります。
Windows フォームのアプリで作る方法は、こちらの記事に書いています。
ConPTY の仕組みの解説は、その記事にあります。
https://qiita.com/Mimumumimu/items/faf040f7aacfbe57c966
やること
- 下のリンクの
Terminalフォルダ (3 ファイル)と、WinUI3/TerminalView.csを、プロジェクトに追加する。名前空間は付けていないので、そのまま使えます。 - ウィンドウの XAML に、黒い画面を置く
Gridを足す。コードでそこにTerminalViewを入れて、部品をつなぐ。足す内容は、「部品をつなぐ」の節にあります。 - ボタンから送る:
_writer?.SendLine("dir /b");
知っておくこと
-
Terminalフォルダの 3 ファイルは、Windows フォーム版と同じファイルです。 - WebView2 の NuGet パッケージは、足さなくて済みました。Windows App SDK だけで、WebView2 が使えます。
- 4 つのファイルは、お互いのことを知りません。使いたいものだけを選べます。
- xterm.js を CDN から読み込むので、インターネットへの接続が要ります。
- MIT ライセンスで自由に使えます。
- 共通の 3 ファイルのフォルダへの直接のリンク: https://github.com/Mimumumimu/CodeSamples/tree/main/ConPtyTerminal/Terminal
- 画面の部品のファイルへの直接のリンク: https://github.com/Mimumumimu/CodeSamples/blob/main/ConPtyTerminal/WinUI3/TerminalView.cs
次のようなことで探している人向けの記事です。
- WinUI 3 のウィンドウに、コマンドプロンプトの画面を出したい
- WinUI 3 のアプリに、DOS 窓のようなターミナルを埋め込みたい
- 確認を聞いてくる対話型のコマンドを、アプリの中で動かしたい
- ボタンを押したら、決まったコマンドを送りたい
向かない場面
コマンドを 1 回動かして結果の文字だけが欲しいときは、Process.Start で標準出力を読めば足ります。
こちらのほうが簡単です。
端末かどうかで動きを変えるコマンドを動かしたいときに、ConPTY を使います。
前提
- Windows 11 の WinUI 3 のアプリです
- パッケージ化しない形の Windows App SDK 2.5.1 と .NET 10 で動かして確かめました
- インターネットへの接続。xterm.js をインターネットから読み込みます
- パッケージ化する形は、試していません
Windows フォーム版と違うところ
部品は 4 つで、Windows フォーム版と同じ構成です。
共通の 3 ファイルは、WinUI 3 でも、そのまま使えました。
変わるのは、画面を描く TerminalView と、それをつなぐウィンドウの側です。
| 項目 | Windows フォーム版 | WinUI 3 版 |
|---|---|---|
| 共通の 3 ファイル | そのまま使う | そのまま使う |
| 画面の部品 | Windows フォームの UserControl
|
WinUI 3 の UserControl。XAML を持たない |
| 準備を始める所 | Load |
Loaded。1 回だけにする |
| 画面のスレッドへ戻す | BeginInvoke |
DispatcherQueue.TryEnqueue |
| 閉じるとき | FormClosing |
Window.Closed |
| WebView2 のパッケージ |
Microsoft.Web.WebView2 を入れる |
足さなくてよい |
Main |
[STAThread] を書く |
XAML のビルドが作るので、書かない |
部品をつなぐ
ウィンドウが、4 つの部品をつなぎます。
部品がお互いを知らないので、つなぎ方はウィンドウの中にまとまります。
先に、XAML に、黒い画面を置く枠を足します。
TerminalView は名前空間を付けていないので、XAML から指定しにくく、コードでこの Grid に入れます。
<!-- 黒い画面 (TerminalView)は、コードでこの Grid に入れる -->
<Grid x:Name="TerminalHost" Grid.Row="0" />
次に、フィールドと、イベントの結びつけです。
private readonly TerminalView _view = new();
private PseudoConsole? _console;
private ShellReader? _reader;
private ShellWriter? _writer;
public MainWindow()
{
InitializeComponent();
Title = "ターミナル";
AppWindow.Resize(new Windows.Graphics.SizeInt32(1000, 640));
// 名前空間のない部品は XAML から指定しにくいので、Grid にコードで入れる
TerminalHost.Children.Add(_view);
_view.Ready += OnViewReady;
_view.InputTyped += text => _writer?.Send(text); // 画面で打ったキーを、シェルへ
_view.Resized += (columns, rows) => _console?.Resize(columns, rows);
Closed += (_, _) => StopShell();
}
画面の準備ができたら、シェルを起動して、読み込みと送信の部品をつなぎます。
private void OnViewReady(int columns, int rows)
{
_console = PseudoConsole.Start(Shell, columns, rows);
_reader = new ShellReader(_console.Output);
// 読み込みは別のスレッドで動くので、画面に描くときは、画面のスレッドへ戻す
_reader.OutputReceived += text => DispatcherQueue.TryEnqueue(() => _view.Write(text));
_reader.Start();
_writer = new ShellWriter(_console.Input);
}
Windows フォーム版の BeginInvoke を、DispatcherQueue.TryEnqueue に置き換えただけです。
Shell は、起動するシェルの名前です。見本では "cmd.exe" にしています。
"powershell.exe" に変えると、PowerShell を起動できます。
ウィンドウを閉じるときは、シェルを終わらせて、後片付けをします。
private void StopShell()
{
_console?.Close();
_reader?.Wait(TimeSpan.FromSeconds(1));
_console?.Dispose();
_console = null;
}
ボタンからコマンドを送る
ボタンの Click の中に、1 行書くだけです。
private void OnSendClick(object sender, RoutedEventArgs e)
{
_writer?.SendLine("dir /b");
}
SendLine は文字を送って Enter を押します。
Enter を押したくないときは Send を使います。
アプリを起動すると、ウィンドウに黒い画面が出て cmd.exe が動きます。
入力欄と送信ボタンまで付いた完成形の見本は、リポジトリの WinUI3/MainWindow.xaml と WinUI3/MainWindow.xaml.cs にあります。
TerminalView の WinUI 3 用の部分
TerminalView の中身は、Windows フォーム版とほぼ同じです。
変わるのは、WebView2 の準備を始める所と、WebView2 から届くメッセージを受ける関数の引数です。
private async void OnLoaded(object sender, RoutedEventArgs e)
{
// Loaded は何度も来ることがあるので、準備は 1 回だけにする
if (_started)
{
return;
}
_started = true;
await _webView.EnsureCoreWebView2Async();
_webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
_webView.NavigateToString(Html);
}
private void OnWebMessageReceived(CoreWebView2 sender, CoreWebView2WebMessageReceivedEventArgs e)
Windows フォーム版では、1 つ目の引数が object? でした。
WinUI 3 では CoreWebView2 になります。
Loaded は、部品が画面に載るたびに来ます。
別の場所へ動かされて載り直すと、もう一度来て、準備が 2 回走ります。
そのため、1 回だけにする印 _started を付けています。
つまずきやすいところ
日本語入力が英字になる
入力欄で日本語を打ってから、黒い画面をクリックして日本語を打つと、日本語入力はオンのままなのに英字が入ります。
この見本には、回避策を入れていません。
同じ症状の原因と、直し方の 1 つは、こちらの記事に書いています。
https://qiita.com/Mimumumimu/items/525090b30f267262b1b9
その記事のコードは、WebView2 を直接置いた画面のものです。
この見本の TerminalView は WebView2 を中に持っているので、そのまま当てはまるかは、試していません。
設計の判断
TerminalView は、XAML を持たない UserControl
TerminalView は、.xaml ファイルを持たない、コードだけの UserControl です。
XAML を持つと、.xaml と .xaml.cs の 2 ファイルになり、x:Class に名前空間が要ります。
コードだけにすると、1 ファイルで、名前空間なしでコピーできます。
そのかわり、XAML から指定しにくいので、Grid にコードで入れます。
つなぎ方は、ウィンドウのコードビハインド
部品をつなぐ処理は、ウィンドウのコードビハインドに書いています。
この記事で見せたいのは、「自分のウィンドウで部品をつなぐ形」です。
読む人のアプリが MVVM だとは限らないので、ViewModel には分けていません。
全部のコード
Terminal フォルダの 3 ファイルは、Windows フォーム版の記事の「全部のコード」と同じです。
フォルダへの直接のリンクは、上の「やること」の枠にあります。
ここには、WinUI 3 用のファイルを載せます。
先頭に必要な using と #nullable enable を書いてあるので、プロジェクトの設定に関わらずそのままビルドできます。
名前空間は付けていません。付けたいときは、4 つとも同じ名前空間にしてください。
WinUI3/TerminalView.cs
#nullable enable
using System;
using System.Text.Json;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
/// <summary>
/// xterm.js で、ターミナルの画面を描く部品。
/// シェルのことは知らない。文字を渡されたら描き、キーが打たれたら知らせるだけ。
/// </summary>
public sealed class TerminalView : UserControl
{
private readonly WebView2 _webView = new();
private bool _started;
/// <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()
{
Content = _webView;
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
// Loaded は何度も来ることがあるので、準備は 1 回だけにする
if (_started)
{
return;
}
_started = true;
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(CoreWebView2 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>
""";
}
WinUI3/MainWindow.xaml
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="ConPtyTerminalWinUI3.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 黒い画面 (TerminalView)は、コードでこの Grid に入れる -->
<Grid x:Name="TerminalHost" Grid.Row="0" />
<Grid Grid.Row="1" Padding="12" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="InputBox"
Grid.Column="0"
PlaceholderText="コマンドを入力"
KeyDown="OnInputKeyDown" />
<Button Grid.Column="1"
Content="送信"
Style="{StaticResource AccentButtonStyle}"
Click="OnSendClick" />
</Grid>
</Grid>
</Window>
WinUI3/MainWindow.xaml.cs
#nullable enable
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.System;
namespace ConPtyTerminalWinUI3;
/// <summary>
/// 部品をつなぐだけのウィンドウ。
/// 画面 (TerminalView)・出力の読み込み (ShellReader)・入力の送信 (ShellWriter) は、お互いを知らない。
/// </summary>
public sealed partial class MainWindow : Window
{
/// <summary>起動するシェル。PowerShell にしたいときは "powershell.exe" にする</summary>
private const string Shell = "cmd.exe";
private readonly TerminalView _view = new();
private PseudoConsole? _console;
private ShellReader? _reader;
private ShellWriter? _writer;
public MainWindow()
{
InitializeComponent();
Title = "ターミナル";
AppWindow.Resize(new Windows.Graphics.SizeInt32(1000, 640));
// 名前空間のない部品は XAML から指定しにくいので、Grid にコードで入れる
TerminalHost.Children.Add(_view);
_view.Ready += OnViewReady;
_view.InputTyped += text => _writer?.Send(text); // 画面で打ったキーを、シェルへ
_view.Resized += (columns, rows) => _console?.Resize(columns, rows);
Closed += (_, _) => StopShell();
}
/// <summary>画面の準備ができたら、シェルを起動して、読み込みと送信の部品をつなぐ</summary>
private void OnViewReady(int columns, int rows)
{
_console = PseudoConsole.Start(Shell, columns, rows);
_reader = new ShellReader(_console.Output);
// 読み込みは別のスレッドで動くので、画面に描くときは、画面のスレッドへ戻す
_reader.OutputReceived += text => DispatcherQueue.TryEnqueue(() => _view.Write(text));
_reader.Start();
_writer = new ShellWriter(_console.Input);
}
private void OnSendClick(object sender, RoutedEventArgs e) => SendFromInputBox();
/// <summary>入力欄で Enter を押しても、送信できる</summary>
private void OnInputKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Enter)
{
SendFromInputBox();
e.Handled = true;
}
}
/// <summary>入力欄の文字を、シェルへ送る</summary>
private void SendFromInputBox()
{
_writer?.SendLine(InputBox.Text);
InputBox.Text = "";
}
/// <summary>シェルを終わらせて、後片付けをする</summary>
private void StopShell()
{
_console?.Close();
_reader?.Wait(TimeSpan.FromSeconds(1));
_console?.Dispose();
_console = null;
}
}
この仕組みが動いているところ
MmmTool は Windows 11 のタスクトレイに常駐する WinUI 3 のツールで、Claude Code や Kiro を動かす画面に、この仕組みを使っています。
AI と一緒に作りました。どう動くかは自分で決めて、気に入らないところは指示して直しています。