From 155c7173673fb01f928830ab937ca37a8b3e10fe Mon Sep 17 00:00:00 2001 From: BDisp Date: Mon, 24 Nov 2025 21:15:01 +0000 Subject: [PATCH 1/5] UICatalog scenario to open another scenario process and return some result --- .../OpenProcess/OpenChildInAnotherProcess.cs | 74 +++++++++++++++++++ .../Scenarios/OpenProcess/RunChildProcess.cs | 74 +++++++++++++++++++ Examples/UICatalog/UICatalog.cs | 23 +++++- .../UICatalog/UICatalogCommandLineOptions.cs | 4 + 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs create mode 100644 Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs diff --git a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs new file mode 100644 index 0000000000..0500cce3b3 --- /dev/null +++ b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs @@ -0,0 +1,74 @@ +#nullable enable + +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("OpenChildInAnotherProcess", "Open Child In Another Process")] +[ScenarioCategory ("Application")] +public sealed class OpenChildInAnotherProcess : Scenario +{ + public override void Main () + { + IApplication app = Application.Create (); + + app.Init (); + + // Setup - Create a top-level application window and configure it. + Window appWindow = new () + { + Title = GetQuitKeyAndName (), + BorderStyle = LineStyle.None + }; + + var label = new Label { X = Pos.Center (), Y = 3 }; + + var button = new Button () + { + X = Pos.Center (), + Y = 1, + Title = "_Open Child In Another Process", + }; + + button.Accepting += async (_, e) => + { + // When Accepting is handled, set e.Handled to true to prevent further processing. + button.Enabled = false; + e.Handled = true; + label.Text = await OpenNewTerminalWindowAsync ("EditName"); + button.Enabled = true; + }; + + appWindow.Add (button, label); + + app.Run (appWindow); + appWindow.Dispose (); + + app.Shutdown (); + } + + public async Task OpenNewTerminalWindowAsync (string action) + { + string pipeName = "RunChildProcess"; + + // Start named pipe server before launching child + var server = new NamedPipeServerStream (pipeName, PipeDirection.In); + + // Launch external console process running UICatalog app again + var p = new Process (); + p.StartInfo.FileName = Environment.ProcessPath!; + p.StartInfo.Arguments = $"{pipeName} --child --action \"{action}\""; + p.StartInfo.UseShellExecute = true; // Needed so it opens a new terminal window + p.Start (); + + // Wait for connection from child + await server.WaitForConnectionAsync (); + + using var reader = new StreamReader (server); + string json = await reader.ReadToEndAsync (); + + return JsonSerializer.Deserialize (json)!; + } +} diff --git a/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs new file mode 100644 index 0000000000..fd44c3e724 --- /dev/null +++ b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs @@ -0,0 +1,74 @@ +#nullable enable + +using System.IO.Pipes; +using System.Text.Json; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("RunChildProcess", "Run Child Process from Open Child In Another Process")] +[ScenarioCategory ("Application")] +public sealed class RunChildProcess : Scenario +{ + public static async Task RunChildAsync (string pipeName, string action) + { + // Run your Terminal.Gui UI + object result = await RunMyDialogAsync (action); + + // Send result back + await using var client = new NamedPipeClientStream (".", pipeName, PipeDirection.Out); + await client.ConnectAsync (); + + string json = JsonSerializer.Serialize (result); + await using var writer = new StreamWriter (client); + await writer.WriteAsync (json); + await writer.FlushAsync (); + } + + public static Task RunMyDialogAsync (string action) + { + TaskCompletionSource tcs = new (); + string? result = null; + + IApplication app = Application.Create (); + + app.Init (); + + var win = new Window () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Title = $"Child Window: {action}" + }; + + var input = new TextField + { + X = 1, + Y = 1, + Width = 30 + }; + + var ok = new Button + { + X = 1, + Y = 3, + Text = "Ok", + IsDefault = true + }; + ok.Accepting += (_, e) => + { + result = input.Text; + app.RequestStop (); + e.Handled = true; + }; + + win.Add (input, ok); + + app.Run (win); + win.Dispose (); + app.Shutdown (); + + tcs.SetResult (result ?? string.Empty); + + return tcs.Task; + } +} diff --git a/Examples/UICatalog/UICatalog.cs b/Examples/UICatalog/UICatalog.cs index 884e65fd18..225be60dd8 100644 --- a/Examples/UICatalog/UICatalog.cs +++ b/Examples/UICatalog/UICatalog.cs @@ -25,6 +25,7 @@ using Serilog; using Serilog.Core; using Serilog.Events; +using UICatalog.Scenarios; using Command = Terminal.Gui.Input.Command; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -141,9 +142,18 @@ private static int Main (string [] args) .ToArray () ); + Option childOption = new ("--child", "Run in child mode"); + + Option actionOption = new ( + name: "--action", + description: "Optional action for child window", + getDefaultValue: () => string.Empty + ); + var rootCommand = new RootCommand ("A comprehensive sample library and test app for Terminal.Gui") { - scenarioArgument, debugLogLevel, benchmarkFlag, benchmarkTimeout, resultsFile, driverOption, disableConfigManagement + scenarioArgument, debugLogLevel, benchmarkFlag, benchmarkTimeout, resultsFile, driverOption, disableConfigManagement, + childOption, actionOption }; rootCommand.SetHandler ( @@ -157,7 +167,9 @@ private static int Main (string [] args) Benchmark = context.ParseResult.GetValueForOption (benchmarkFlag), BenchmarkTimeout = context.ParseResult.GetValueForOption (benchmarkTimeout), ResultsFile = context.ParseResult.GetValueForOption (resultsFile) ?? string.Empty, - DebugLogLevel = context.ParseResult.GetValueForOption (debugLogLevel) ?? "Warning" + DebugLogLevel = context.ParseResult.GetValueForOption (debugLogLevel) ?? "Warning", + IsChild = context.ParseResult.GetValueForOption (childOption), + Action = context.ParseResult.GetValueForOption (actionOption) ?? string.Empty /* etc. */ }; @@ -380,6 +392,13 @@ private static void UICatalogMain (UICatalogCommandLineOptions options) )!); UICatalogTop.CachedSelectedScenario = (Scenario)Activator.CreateInstance (UICatalogTop.CachedScenarios [item].GetType ())!; + if (options.IsChild) + { + Task.Run (async () => await RunChildProcess.RunChildAsync (UICatalogTop.CachedSelectedScenario.GetName (), options.Action)).Wait (); + + return; + } + BenchmarkResults? results = RunScenario (UICatalogTop.CachedSelectedScenario, options.Benchmark); if (results is { }) diff --git a/Examples/UICatalog/UICatalogCommandLineOptions.cs b/Examples/UICatalog/UICatalogCommandLineOptions.cs index b297c06ee4..417b295f23 100644 --- a/Examples/UICatalog/UICatalogCommandLineOptions.cs +++ b/Examples/UICatalog/UICatalogCommandLineOptions.cs @@ -16,5 +16,9 @@ public struct UICatalogCommandLineOptions public string ResultsFile { get; set; } public string DebugLogLevel { get; set; } + + public bool IsChild { get; set; } + + public string Action { get; set; } /* etc. */ } From 56fc0f9ebe09c6856c67b970c8b90770189f9fcd Mon Sep 17 00:00:00 2001 From: BDisp Date: Mon, 24 Nov 2025 21:26:55 +0000 Subject: [PATCH 2/5] Fix unit test --- .../UICatalog/Scenarios/OpenProcess/RunChildProcess.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs index fd44c3e724..f1c67ff8eb 100644 --- a/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs +++ b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs @@ -9,6 +9,16 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Application")] public sealed class RunChildProcess : Scenario { + /// + public override void Main () + { + IApplication app = Application.Create (); + app.Init (); + app.Run (); + app.TopRunnable?.Dispose (); + app.Shutdown (); + } + public static async Task RunChildAsync (string pipeName, string action) { // Run your Terminal.Gui UI From c7acad07ee9fec2227835068f127a7fb7d51e1aa Mon Sep 17 00:00:00 2001 From: BDisp Date: Mon, 24 Nov 2025 22:29:36 +0000 Subject: [PATCH 3/5] Only work with legacy static --- .../Scenarios/OpenProcess/OpenChildInAnotherProcess.cs | 9 ++++----- .../UICatalog/Scenarios/OpenProcess/RunChildProcess.cs | 10 +++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs index 0500cce3b3..e72725a0e1 100644 --- a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs +++ b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs @@ -12,9 +12,8 @@ public sealed class OpenChildInAnotherProcess : Scenario { public override void Main () { - IApplication app = Application.Create (); - - app.Init (); + // Only work with legacy + Application.Init (); // Setup - Create a top-level application window and configure it. Window appWindow = new () @@ -43,10 +42,10 @@ public override void Main () appWindow.Add (button, label); - app.Run (appWindow); + Application.Run (appWindow); appWindow.Dispose (); - app.Shutdown (); + Application.Shutdown (); } public async Task OpenNewTerminalWindowAsync (string action) diff --git a/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs index f1c67ff8eb..f2b4325441 100644 --- a/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs +++ b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs @@ -12,11 +12,11 @@ public sealed class RunChildProcess : Scenario /// public override void Main () { - IApplication app = Application.Create (); - app.Init (); - app.Run (); - app.TopRunnable?.Dispose (); - app.Shutdown (); + // Only work with legacy + Application.Init (); + Application.Run (); + Application.TopRunnable?.Dispose (); + Application.Shutdown (); } public static async Task RunChildAsync (string pipeName, string action) From 7ddd5e40d6ec1c1f5fb33bf64cbfb668889386c2 Mon Sep 17 00:00:00 2001 From: BDisp Date: Mon, 24 Nov 2025 23:38:11 +0000 Subject: [PATCH 4/5] Add support for Unix OS --- .../OpenProcess/OpenChildInAnotherProcess.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs index e72725a0e1..f6c040983d 100644 --- a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs +++ b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO.Pipes; +using System.Reflection; using System.Text.Json; namespace UICatalog.Scenarios; @@ -57,9 +58,27 @@ public async Task OpenNewTerminalWindowAsync (string action) // Launch external console process running UICatalog app again var p = new Process (); - p.StartInfo.FileName = Environment.ProcessPath!; - p.StartInfo.Arguments = $"{pipeName} --child --action \"{action}\""; - p.StartInfo.UseShellExecute = true; // Needed so it opens a new terminal window + + if (OperatingSystem.IsWindows ()) + { + p.StartInfo.FileName = Environment.ProcessPath!; + p.StartInfo.Arguments = $"{pipeName} --child --action \"{action}\""; + p.StartInfo.UseShellExecute = true; // Needed so it opens a new terminal window + } + else + { + p.StartInfo.FileName = "gnome-terminal"; + // Use -- to avoid TTY reuse + p.StartInfo.ArgumentList.Add ("--"); + p.StartInfo.ArgumentList.Add ("bash"); + p.StartInfo.ArgumentList.Add ("-c"); + p.StartInfo.ArgumentList.Add ($"dotnet {Assembly.GetExecutingAssembly ().Location} {pipeName} --child --action \"{action}\""); + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardInput = false; + p.StartInfo.RedirectStandardOutput = false; + p.StartInfo.RedirectStandardError = false; + } + p.Start (); // Wait for connection from child From a9e1b4a847d7c3a0ee5f060d1a32544b3c0b0bdd Mon Sep 17 00:00:00 2001 From: BDisp Date: Wed, 26 Nov 2025 13:01:53 +0000 Subject: [PATCH 5/5] Add more unix terminal support --- .../OpenProcess/OpenChildInAnotherProcess.cs | 229 ++++++++++++++++-- 1 file changed, 212 insertions(+), 17 deletions(-) diff --git a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs index f6c040983d..585c8c9a86 100644 --- a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs +++ b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO.Pipes; using System.Reflection; +using System.Runtime.InteropServices; using System.Text.Json; namespace UICatalog.Scenarios; @@ -25,11 +26,11 @@ public override void Main () var label = new Label { X = Pos.Center (), Y = 3 }; - var button = new Button () + var button = new Button { X = Pos.Center (), Y = 1, - Title = "_Open Child In Another Process", + Title = "_Open Child In Another Process" }; button.Accepting += async (_, e) => @@ -37,7 +38,7 @@ public override void Main () // When Accepting is handled, set e.Handled to true to prevent further processing. button.Enabled = false; e.Handled = true; - label.Text = await OpenNewTerminalWindowAsync ("EditName"); + label.Text = await OpenNewTerminalWindowAsync ("EditName") ?? string.Empty; button.Enabled = true; }; @@ -49,9 +50,9 @@ public override void Main () Application.Shutdown (); } - public async Task OpenNewTerminalWindowAsync (string action) + public static async Task OpenNewTerminalWindowAsync (string action) { - string pipeName = "RunChildProcess"; + var pipeName = "RunChildProcess"; // Start named pipe server before launching child var server = new NamedPipeServerStream (pipeName, PipeDirection.In); @@ -63,23 +64,26 @@ public async Task OpenNewTerminalWindowAsync (string action) { p.StartInfo.FileName = Environment.ProcessPath!; p.StartInfo.Arguments = $"{pipeName} --child --action \"{action}\""; - p.StartInfo.UseShellExecute = true; // Needed so it opens a new terminal window + p.StartInfo.UseShellExecute = true; // Needed so it opens a new terminal window } else { - p.StartInfo.FileName = "gnome-terminal"; - // Use -- to avoid TTY reuse - p.StartInfo.ArgumentList.Add ("--"); - p.StartInfo.ArgumentList.Add ("bash"); - p.StartInfo.ArgumentList.Add ("-c"); - p.StartInfo.ArgumentList.Add ($"dotnet {Assembly.GetExecutingAssembly ().Location} {pipeName} --child --action \"{action}\""); - p.StartInfo.UseShellExecute = false; - p.StartInfo.RedirectStandardInput = false; - p.StartInfo.RedirectStandardOutput = false; - p.StartInfo.RedirectStandardError = false; + var executable = $"dotnet {Assembly.GetExecutingAssembly ().Location}"; + var arguments = $"{pipeName} --child --action \"{action}\""; + UnixTerminalHelper.AdjustTerminalProcess (executable, arguments, p); } - p.Start (); + try + { + p.Start (); + } + catch (Exception ex) + { + // Catch any other unexpected exception + Console.WriteLine ($@"Failed to launch terminal: {ex.Message}"); + + return default (T?); + } // Wait for connection from child await server.WaitForConnectionAsync (); @@ -90,3 +94,194 @@ public async Task OpenNewTerminalWindowAsync (string action) return JsonSerializer.Deserialize (json)!; } } + +public static class UnixTerminalHelper +{ + private static readonly string [] _knownTerminals = + { + // Linux + "gnome-terminal", + "konsole", + "xfce4-terminal", + "xterm", + "lxterminal", + "tilix", + "mate-terminal", + "alacritty", + "terminator", + + // macOS + "Terminal", "iTerm" + }; + + public static void AdjustTerminalProcess (string executable, string arguments, Process p) + { + var command = $"{executable} {arguments}"; + var escaped = $"{command.Replace ("\"", "\\\"")} && exit"; + string script; + string? terminal = DetectTerminalProcess (); + + if (IsRunningOnWsl ()) + { + terminal = "cmd.exe"; + } + else if (terminal is null) + { + throw new InvalidOperationException ( + "No supported terminal emulator found. Install gnome-terminal, xterm, konsole, etc."); + } + + p.StartInfo.FileName = OperatingSystem.IsMacOS () ? "osascript" : terminal; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardInput = false; + p.StartInfo.RedirectStandardOutput = false; + p.StartInfo.RedirectStandardError = false; + + // Use -- to avoid TTY reuse + switch (terminal) + { + case "cmd.exe": + p.StartInfo.ArgumentList.Add ("/c"); + p.StartInfo.ArgumentList.Add ($"start wsl {command}"); + + break; + case "Terminal": + script = $""" + tell application "Terminal" + activate + do script "{escaped}" + end tell + """; + + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add (script); + + break; + case "iTerm": + script = $""" + + tell application "iTerm" + create window with default profile + tell current session of current window + write text "{escaped}" + end tell + end tell + """; + + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add (script); + + break; + case "gnome-terminal": + case "tilix": + case "mate-terminal": + p.StartInfo.ArgumentList.Add ("--"); + p.StartInfo.ArgumentList.Add ("bash"); + p.StartInfo.ArgumentList.Add ("-c"); + p.StartInfo.ArgumentList.Add (command); + + break; + case "konsole": + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add ($"bash -c \"{command}\""); + + break; + case "xfce4-terminal": + case "lxterminal": + p.StartInfo.ArgumentList.Add ("--command"); + p.StartInfo.ArgumentList.Add ($"bash -c \"{command}\""); + + break; + case "xterm": + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add ($"bash -c \"{command}\""); + + break; + default: + throw new NotSupportedException ($"Terminal detected but unsupported mapping: {terminal}"); + } + } + + public static string? DetectTerminalProcess () + { + int pid = Process.GetCurrentProcess ().Id; + + while (pid > 1) + { + int? ppid = GetParentProcessId (pid); + + if (ppid is null) + { + break; + } + + try + { + var parent = Process.GetProcessById (ppid.Value); + + string? match = _knownTerminals + .FirstOrDefault (t => parent.ProcessName.Contains (t, StringComparison.OrdinalIgnoreCase)); + + if (match is { }) + { + return match; + } + + pid = parent.Id; + } + catch + { + break; + } + } + + return null; // unknown + } + + public static bool IsRunningOnWsl () + { + if (Environment.GetEnvironmentVariable ("WSL_DISTRO_NAME") != null) + { + return true; + } + + if (File.Exists ("/proc/sys/kernel/osrelease") + && File.ReadAllText ("/proc/sys/kernel/osrelease") + .Contains ("microsoft", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static int? GetParentPidUnix (int pid) + { + try + { + string output = Process.Start ( + new ProcessStartInfo + { + FileName = "ps", + ArgumentList = { "-o", "ppid=", "-p", pid.ToString () }, + RedirectStandardOutput = true + })!.StandardOutput.ReadToEnd (); + + return int.TryParse (output.Trim (), out int ppid) ? ppid : null; + } + catch + { + return null; + } + } + + private static int? GetParentProcessId (int pid) + { + if (!RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) + { + return GetParentPidUnix (pid); + } + + return null; + } +}