aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-02-24 15:36:59 +0100
committerGitHub Enterprise <[email protected]>2026-02-24 15:36:59 +0100
commit3cfc1b18f6b86b9830730f0055b8e3b955b77c95 (patch)
treea4ab26cbfe2b30580408685634b74934936c2ee6
parentVarious bug fixes (#778) (diff)
downloadzen-3cfc1b18f6b86b9830730f0055b8e3b955b77c95.tar.xz
zen-3cfc1b18f6b86b9830730f0055b8e3b955b77c95.zip
Add `zen ui` command (#779)
Allows user to automate launching of zenserver dashboard, including when multiple instances are running. If multiple instances are running you can open all dashboards with `--all`, and also using the in-terminal chooser which also allows you to open a specific instance. Also includes a fix to `zen exec` when using offset/stride/limit
-rw-r--r--CHANGELOG.md1
-rw-r--r--src/zen/cmds/exec_cmd.cpp9
-rw-r--r--src/zen/cmds/ui_cmd.cpp236
-rw-r--r--src/zen/cmds/ui_cmd.h32
-rw-r--r--src/zen/progressbar.cpp55
-rw-r--r--src/zen/progressbar.h1
-rw-r--r--src/zen/zen.cpp6
-rw-r--r--src/zencore/include/zencore/process.h1
-rw-r--r--src/zencore/process.cpp226
-rw-r--r--src/zenutil/consoletui.cpp483
-rw-r--r--src/zenutil/include/zenutil/consoletui.h59
11 files changed, 1055 insertions, 54 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af2414682..3670e451e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,5 @@
##
+- Feature: `zen ui` can be used to open dashboards for local instances
- Bugfix: `--plain-progress` style progress bar should now show elapsed time correctly
- Bugfix: Time spent indexing local and remote state during `zen builds download` now show the correct time
diff --git a/src/zen/cmds/exec_cmd.cpp b/src/zen/cmds/exec_cmd.cpp
index 2d9d0d12e..407f42ee3 100644
--- a/src/zen/cmds/exec_cmd.cpp
+++ b/src/zen/cmds/exec_cmd.cpp
@@ -360,6 +360,13 @@ ExecCommand::ExecUsingSession(zen::compute::FunctionServiceSession& FunctionSess
return false;
};
+ int TargetParallelism = 8;
+
+ if (OffsetCounter || StrideCounter || m_Limit)
+ {
+ TargetParallelism = 1;
+ }
+
m_RecordingReader->IterateActions(
[&](CbObject ActionObject, const IoHash& ActionId) {
// Enqueue job
@@ -444,7 +451,7 @@ ExecCommand::ExecUsingSession(zen::compute::FunctionServiceSession& FunctionSess
DrainCompletedJobs();
},
- 8);
+ TargetParallelism);
// Wait until all pending work is complete
diff --git a/src/zen/cmds/ui_cmd.cpp b/src/zen/cmds/ui_cmd.cpp
new file mode 100644
index 000000000..da06ce305
--- /dev/null
+++ b/src/zen/cmds/ui_cmd.cpp
@@ -0,0 +1,236 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "ui_cmd.h"
+
+#include <zencore/except_fmt.h>
+#include <zencore/fmtutils.h>
+#include <zencore/logging.h>
+#include <zencore/process.h>
+#include <zenutil/consoletui.h>
+#include <zenutil/zenserverprocess.h>
+
+#if ZEN_PLATFORM_WINDOWS
+# include <zencore/windows.h>
+# include <shellapi.h>
+#endif
+
+namespace zen {
+
+namespace {
+
+ struct RunningServerInfo
+ {
+ uint16_t Port;
+ uint32_t Pid;
+ std::string SessionId;
+ std::string CmdLine;
+ };
+
+ static std::vector<RunningServerInfo> CollectRunningServers()
+ {
+ std::vector<RunningServerInfo> Servers;
+ ZenServerState State;
+ if (!State.InitializeReadOnly())
+ return Servers;
+
+ State.Snapshot([&](const ZenServerState::ZenServerEntry& Entry) {
+ StringBuilder<25> SessionSB;
+ Entry.GetSessionId().ToString(SessionSB);
+ std::error_code CmdLineEc;
+ std::string CmdLine = GetProcessCommandLine(static_cast<int>(Entry.Pid.load()), CmdLineEc);
+ Servers.push_back({Entry.EffectiveListenPort.load(), Entry.Pid.load(), std::string(SessionSB.c_str()), std::move(CmdLine)});
+ });
+
+ return Servers;
+ }
+
+} // namespace
+
+UiCommand::UiCommand()
+{
+ m_Options.add_options()("h,help", "Print help");
+ m_Options.add_options()("a,all", "Open dashboard for all running instances", cxxopts::value(m_All)->default_value("false"));
+ m_Options.add_option("", "u", "hosturl", "Host URL", cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+ m_Options.add_option("",
+ "p",
+ "path",
+ "Dashboard path (default: /dashboard/)",
+ cxxopts::value(m_DashboardPath)->default_value("/dashboard/"),
+ "<path>");
+ m_Options.parse_positional("path");
+}
+
+UiCommand::~UiCommand()
+{
+}
+
+void
+UiCommand::OpenBrowser(std::string_view HostName)
+{
+ // Allow shortcuts for specifying dashboard path, and ensure it is in a format we expect
+ // (leading slash, trailing slash if no file extension)
+
+ if (!m_DashboardPath.empty())
+ {
+ if (m_DashboardPath[0] != '/')
+ {
+ m_DashboardPath = "/dashboard/" + m_DashboardPath;
+ }
+
+ if (m_DashboardPath.find_last_of('.') == std::string::npos && m_DashboardPath.back() != '/')
+ {
+ m_DashboardPath += '/';
+ }
+ }
+
+ bool Success = false;
+
+ ExtendableStringBuilder<256> FullUrl;
+ FullUrl << HostName << m_DashboardPath;
+
+#if ZEN_PLATFORM_WINDOWS
+ HINSTANCE Result = ShellExecuteA(nullptr, "open", FullUrl.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
+ Success = reinterpret_cast<intptr_t>(Result) > 32;
+#else
+ // Validate URL doesn't contain shell metacharacters that could lead to command injection
+ std::string_view FullUrlView = FullUrl;
+ constexpr std::string_view DangerousChars = ";|&$`\\\"'<>(){}[]!#*?~\n\r";
+ if (FullUrlView.find_first_of(DangerousChars) != std::string_view::npos)
+ {
+ throw OptionParseException(fmt::format("URL contains invalid characters: '{}'", FullUrl), m_Options.help());
+ }
+
+# if ZEN_PLATFORM_MAC
+ std::string Command = fmt::format("open \"{}\"", FullUrl);
+# elif ZEN_PLATFORM_LINUX
+ std::string Command = fmt::format("xdg-open \"{}\"", FullUrl);
+# else
+ ZEN_NOT_IMPLEMENTED("Browser launching not implemented on this platform");
+# endif
+
+ Success = system(Command.c_str()) == 0;
+#endif
+
+ if (!Success)
+ {
+ throw zen::runtime_error("Failed to launch browser for '{}'", FullUrl);
+ }
+
+ ZEN_CONSOLE("Web browser launched for '{}' successfully", FullUrl);
+}
+
+void
+UiCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
+{
+ using namespace std::literals;
+
+ ZEN_UNUSED(GlobalOptions);
+
+ if (!ParseOptions(argc, argv))
+ {
+ return;
+ }
+
+ // Resolve target server
+ uint16_t ServerPort = 0;
+
+ if (m_HostName.empty())
+ {
+ // Auto-discover running instances.
+ std::vector<RunningServerInfo> Servers = CollectRunningServers();
+
+ if (m_All)
+ {
+ if (Servers.empty())
+ {
+ throw OptionParseException("No running Zen server instances found", m_Options.help());
+ }
+
+ for (const auto& Server : Servers)
+ {
+ OpenBrowser(fmt::format("http://localhost:{}", Server.Port));
+ }
+ return;
+ }
+
+ // If multiple are found and we have an interactive terminal, present a picker
+ // instead of silently using the first one.
+ if (Servers.size() > 1 && IsTuiAvailable())
+ {
+ std::vector<std::string> Labels;
+ Labels.reserve(Servers.size() + 1);
+ Labels.push_back(fmt::format("(all {} instances)", Servers.size()));
+
+ const int32_t Cols = static_cast<int32_t>(TuiConsoleColumns());
+ constexpr int32_t kIndicator = 3; // " ▶ " or " " prefix
+ constexpr int32_t kSeparator = 2; // " " before cmdline
+ constexpr int32_t kEllipsis = 3; // "..."
+
+ for (const auto& Server : Servers)
+ {
+ std::string Label = fmt::format("port {:<5} pid {:<7} session {}", Server.Port, Server.Pid, Server.SessionId);
+
+ if (!Server.CmdLine.empty())
+ {
+ int32_t Available = Cols - kIndicator - kSeparator - static_cast<int32_t>(Label.size());
+ if (Available > kEllipsis)
+ {
+ Label += " ";
+ if (static_cast<int32_t>(Server.CmdLine.size()) <= Available)
+ {
+ Label += Server.CmdLine;
+ }
+ else
+ {
+ Label.append(Server.CmdLine, 0, static_cast<size_t>(Available - kEllipsis));
+ Label += "...";
+ }
+ }
+ }
+
+ Labels.push_back(std::move(Label));
+ }
+
+ int SelectedIdx = TuiPickOne("Multiple Zen server instances found. Select one to open:", Labels);
+ if (SelectedIdx < 0)
+ return; // User cancelled
+
+ if (SelectedIdx == 0)
+ {
+ // "All" selected
+ for (const auto& Server : Servers)
+ {
+ OpenBrowser(fmt::format("http://localhost:{}", Server.Port));
+ }
+ return;
+ }
+
+ ServerPort = Servers[SelectedIdx - 1].Port;
+ m_HostName = fmt::format("http://localhost:{}", ServerPort);
+ }
+
+ if (m_HostName.empty())
+ {
+ // Single or zero instances, or not an interactive terminal:
+ // fall back to default resolution (picks first instance or returns empty)
+ m_HostName = ResolveTargetHostSpec("", ServerPort);
+ }
+ }
+ else
+ {
+ if (m_All)
+ {
+ throw OptionParseException("--all cannot be used together with --hosturl", m_Options.help());
+ }
+ m_HostName = ResolveTargetHostSpec(m_HostName, ServerPort);
+ }
+
+ if (m_HostName.empty())
+ {
+ throw OptionParseException("Unable to resolve server specification", m_Options.help());
+ }
+
+ OpenBrowser(m_HostName);
+}
+
+} // namespace zen
diff --git a/src/zen/cmds/ui_cmd.h b/src/zen/cmds/ui_cmd.h
new file mode 100644
index 000000000..c74cdbbd0
--- /dev/null
+++ b/src/zen/cmds/ui_cmd.h
@@ -0,0 +1,32 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include "../zen.h"
+
+#include <filesystem>
+
+namespace zen {
+
+class UiCommand : public ZenCmdBase
+{
+public:
+ UiCommand();
+ ~UiCommand();
+
+ static constexpr char Name[] = "ui";
+ static constexpr char Description[] = "Launch web browser with zen server UI";
+
+ virtual void Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) override;
+ virtual cxxopts::Options& Options() override { return m_Options; }
+
+private:
+ void OpenBrowser(std::string_view HostName);
+
+ cxxopts::Options m_Options{Name, Description};
+ std::string m_HostName;
+ std::string m_DashboardPath = "/dashboard/";
+ bool m_All = false;
+};
+
+} // namespace zen
diff --git a/src/zen/progressbar.cpp b/src/zen/progressbar.cpp
index 1ee1d1e71..732f16e81 100644
--- a/src/zen/progressbar.cpp
+++ b/src/zen/progressbar.cpp
@@ -8,16 +8,12 @@
#include <zencore/logging.h>
#include <zencore/windows.h>
#include <zenremotestore/operationlogoutput.h>
+#include <zenutil/consoletui.h>
ZEN_THIRD_PARTY_INCLUDES_START
#include <gsl/gsl-lite.hpp>
ZEN_THIRD_PARTY_INCLUDES_END
-#if ZEN_PLATFORM_LINUX || ZEN_PLATFORM_MAC
-# include <sys/ioctl.h>
-# include <unistd.h>
-#endif
-
//////////////////////////////////////////////////////////////////////////
namespace zen {
@@ -31,35 +27,12 @@ GetConsoleHandle()
}
#endif
-static bool
-CheckStdoutTty()
-{
-#if ZEN_PLATFORM_WINDOWS
- HANDLE hStdOut = GetConsoleHandle();
- DWORD dwMode = 0;
- static bool IsConsole = ::GetConsoleMode(hStdOut, &dwMode);
- return IsConsole;
-#else
- return isatty(fileno(stdout));
-#endif
-}
-
-static bool
-IsStdoutTty()
-{
- static bool StdoutIsTty = CheckStdoutTty();
- return StdoutIsTty;
-}
-
static void
OutputToConsoleRaw(const char* String, size_t Length)
{
#if ZEN_PLATFORM_WINDOWS
HANDLE hStdOut = GetConsoleHandle();
-#endif
-
-#if ZEN_PLATFORM_WINDOWS
- if (IsStdoutTty())
+ if (TuiIsStdoutTty())
{
WriteConsoleA(hStdOut, String, (DWORD)Length, 0, 0);
}
@@ -85,26 +58,6 @@ OutputToConsoleRaw(const StringBuilderBase& SB)
}
uint32_t
-GetConsoleColumns(uint32_t Default)
-{
-#if ZEN_PLATFORM_WINDOWS
- HANDLE hStdOut = GetConsoleHandle();
- CONSOLE_SCREEN_BUFFER_INFO csbi;
- if (GetConsoleScreenBufferInfo(hStdOut, &csbi) == TRUE)
- {
- return (uint32_t)(csbi.srWindow.Right - csbi.srWindow.Left + 1);
- }
-#else
- struct winsize w;
- if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) == 0)
- {
- return (uint32_t)w.ws_col;
- }
-#endif
- return Default;
-}
-
-uint32_t
GetUpdateDelayMS(ProgressBar::Mode InMode)
{
switch (InMode)
@@ -165,7 +118,7 @@ ProgressBar::PopLogOperation(Mode InMode)
}
ProgressBar::ProgressBar(Mode InMode, std::string_view InSubTask)
-: m_Mode((!IsStdoutTty() && InMode == Mode::Pretty) ? Mode::Plain : InMode)
+: m_Mode((!TuiIsStdoutTty() && InMode == Mode::Pretty) ? Mode::Plain : InMode)
, m_LastUpdateMS((uint64_t)-1)
, m_PausedMS(0)
, m_SubTask(InSubTask)
@@ -257,7 +210,7 @@ ProgressBar::UpdateState(const State& NewState, bool DoLinebreak)
uint64_t ETAMS =
(NewState.Status == State::EStatus::Running) && (PercentDone > 5) ? (ETAElapsedMS * NewState.RemainingCount) / Completed : 0;
- uint32_t ConsoleColumns = GetConsoleColumns(1024);
+ uint32_t ConsoleColumns = TuiConsoleColumns(1024);
const std::string PercentString = fmt::format("{:#3}%", PercentDone);
diff --git a/src/zen/progressbar.h b/src/zen/progressbar.h
index bbdb008d4..cb1c7023b 100644
--- a/src/zen/progressbar.h
+++ b/src/zen/progressbar.h
@@ -76,7 +76,6 @@ private:
};
uint32_t GetUpdateDelayMS(ProgressBar::Mode InMode);
-uint32_t GetConsoleColumns(uint32_t Default);
OperationLogOutput* CreateConsoleLogOutput(ProgressBar::Mode InMode);
diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp
index bdc2b4003..dc37cb56b 100644
--- a/src/zen/zen.cpp
+++ b/src/zen/zen.cpp
@@ -22,6 +22,7 @@
#include "cmds/status_cmd.h"
#include "cmds/top_cmd.h"
#include "cmds/trace_cmd.h"
+#include "cmds/ui_cmd.h"
#include "cmds/up_cmd.h"
#include "cmds/version_cmd.h"
#include "cmds/vfs_cmd.h"
@@ -41,6 +42,7 @@
#include <zencore/windows.h>
#include <zenhttp/httpcommon.h>
#include <zenutil/config/environmentoptions.h>
+#include <zenutil/consoletui.h>
#include <zenutil/logging.h>
#include <zenutil/workerpools.h>
#include <zenutil/zenserverprocess.h>
@@ -123,7 +125,7 @@ ZenCmdBase::ParseOptions(int argc, char** argv)
bool
ZenCmdBase::ParseOptions(cxxopts::Options& CmdOptions, int argc, char** argv)
{
- CmdOptions.set_width(GetConsoleColumns(80));
+ CmdOptions.set_width(TuiConsoleColumns(80));
cxxopts::ParseResult Result;
@@ -364,6 +366,7 @@ main(int argc, char** argv)
LoggingCommand LoggingCmd;
TopCommand TopCmd;
TraceCommand TraceCmd;
+ UiCommand UiCmd;
UpCommand UpCmd;
VersionCommand VersionCmd;
VfsCommand VfsCmd;
@@ -425,6 +428,7 @@ main(int argc, char** argv)
{StatusCommand::Name, &StatusCmd, StatusCommand::Description},
{TopCommand::Name, &TopCmd, TopCommand::Description},
{TraceCommand::Name, &TraceCmd, TraceCommand::Description},
+ {UiCommand::Name, &UiCmd, UiCommand::Description},
{UpCommand::Name, &UpCmd, UpCommand::Description},
{VersionCommand::Name, &VersionCmd, VersionCommand::Description},
{VfsCommand::Name, &VfsCmd, VfsCommand::Description},
diff --git a/src/zencore/include/zencore/process.h b/src/zencore/include/zencore/process.h
index e3b7a70d7..c51163a68 100644
--- a/src/zencore/include/zencore/process.h
+++ b/src/zencore/include/zencore/process.h
@@ -105,6 +105,7 @@ int GetCurrentProcessId();
int GetProcessId(CreateProcResult ProcId);
std::filesystem::path GetProcessExecutablePath(int Pid, std::error_code& OutEc);
+std::string GetProcessCommandLine(int Pid, std::error_code& OutEc);
std::error_code FindProcess(const std::filesystem::path& ExecutableImage, ProcessHandle& OutHandle, bool IncludeSelf = true);
/** Wait for all threads in the current process to exit (except the calling thread)
diff --git a/src/zencore/process.cpp b/src/zencore/process.cpp
index 56849a10d..4a2668912 100644
--- a/src/zencore/process.cpp
+++ b/src/zencore/process.cpp
@@ -1001,6 +1001,232 @@ GetProcessExecutablePath(int Pid, std::error_code& OutEc)
#endif // ZEN_PLATFORM_LINUX
}
+std::string
+GetProcessCommandLine(int Pid, std::error_code& OutEc)
+{
+#if ZEN_PLATFORM_WINDOWS
+ HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, static_cast<DWORD>(Pid));
+ if (!hProcess)
+ {
+ OutEc = MakeErrorCodeFromLastError();
+ return {};
+ }
+ auto _ = MakeGuard([hProcess] { CloseHandle(hProcess); });
+
+ // NtQueryInformationProcess is an undocumented NT API; load it dynamically.
+ // Info class 60 = ProcessCommandLine, available since Windows 8.1.
+ using PFN_NtQIP = LONG(WINAPI*)(HANDLE, UINT, PVOID, ULONG, PULONG);
+ static const PFN_NtQIP s_NtQIP =
+ reinterpret_cast<PFN_NtQIP>(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationProcess"));
+ if (!s_NtQIP)
+ {
+ return {};
+ }
+
+ constexpr UINT ProcessCommandLineClass = 60;
+ constexpr LONG StatusInfoLengthMismatch = static_cast<LONG>(0xC0000004L);
+
+ ULONG ReturnLength = 0;
+ LONG Status = s_NtQIP(hProcess, ProcessCommandLineClass, nullptr, 0, &ReturnLength);
+ if (Status != StatusInfoLengthMismatch || ReturnLength == 0)
+ {
+ return {};
+ }
+
+ std::vector<char> Buf(ReturnLength);
+ Status = s_NtQIP(hProcess, ProcessCommandLineClass, Buf.data(), ReturnLength, &ReturnLength);
+ if (Status < 0)
+ {
+ OutEc = MakeErrorCodeFromLastError();
+ return {};
+ }
+
+ // Output: UNICODE_STRING header immediately followed by the UTF-16 string data.
+ // The UNICODE_STRING.Buffer field points into our Buf.
+ struct LocalUnicodeString
+ {
+ USHORT Length;
+ USHORT MaximumLength;
+ WCHAR* Buffer;
+ };
+ if (ReturnLength < sizeof(LocalUnicodeString))
+ {
+ return {};
+ }
+ const auto* Us = reinterpret_cast<const LocalUnicodeString*>(Buf.data());
+ if (Us->Length == 0 || Us->Buffer == nullptr)
+ {
+ return {};
+ }
+
+ // Skip argv[0]: may be a quoted path ("C:\...\exe.exe") or a bare path
+ const WCHAR* p = Us->Buffer;
+ const WCHAR* End = Us->Buffer + Us->Length / sizeof(WCHAR);
+ if (p < End && *p == L'"')
+ {
+ ++p;
+ while (p < End && *p != L'"')
+ {
+ ++p;
+ }
+ if (p < End)
+ {
+ ++p; // skip closing quote
+ }
+ }
+ else
+ {
+ while (p < End && *p != L' ')
+ {
+ ++p;
+ }
+ }
+ while (p < End && *p == L' ')
+ {
+ ++p;
+ }
+ if (p >= End)
+ {
+ return {};
+ }
+
+ int Utf8Size = WideCharToMultiByte(CP_UTF8, 0, p, static_cast<int>(End - p), nullptr, 0, nullptr, nullptr);
+ if (Utf8Size <= 0)
+ {
+ OutEc = MakeErrorCodeFromLastError();
+ return {};
+ }
+ std::string Result(Utf8Size, '\0');
+ WideCharToMultiByte(CP_UTF8, 0, p, static_cast<int>(End - p), Result.data(), Utf8Size, nullptr, nullptr);
+ return Result;
+
+#elif ZEN_PLATFORM_LINUX
+ std::string CmdlinePath = fmt::format("/proc/{}/cmdline", Pid);
+ FILE* F = fopen(CmdlinePath.c_str(), "rb");
+ if (!F)
+ {
+ OutEc = MakeErrorCodeFromLastError();
+ return {};
+ }
+ auto FGuard = MakeGuard([F] { fclose(F); });
+
+ // /proc/{pid}/cmdline contains null-separated argv entries; read it all
+ std::string Raw;
+ char Chunk[4096];
+ size_t BytesRead;
+ while ((BytesRead = fread(Chunk, 1, sizeof(Chunk), F)) > 0)
+ {
+ Raw.append(Chunk, BytesRead);
+ }
+ if (Raw.empty())
+ {
+ return {};
+ }
+
+ // Skip argv[0] (first null-terminated entry)
+ const char* p = Raw.data();
+ const char* End = Raw.data() + Raw.size();
+ while (p < End && *p != '\0')
+ {
+ ++p;
+ }
+ if (p < End)
+ {
+ ++p; // skip null terminator of argv[0]
+ }
+
+ // Build result: remaining entries joined by spaces (inter-arg nulls → spaces)
+ std::string Result;
+ Result.reserve(static_cast<size_t>(End - p));
+ for (const char* q = p; q < End; ++q)
+ {
+ Result += (*q == '\0') ? ' ' : *q;
+ }
+ while (!Result.empty() && Result.back() == ' ')
+ {
+ Result.pop_back();
+ }
+ return Result;
+
+#elif ZEN_PLATFORM_MAC
+ int Mib[3] = {CTL_KERN, KERN_PROCARGS2, Pid};
+ size_t BufSize = 0;
+ if (sysctl(Mib, 3, nullptr, &BufSize, nullptr, 0) != 0 || BufSize == 0)
+ {
+ OutEc = MakeErrorCodeFromLastError();
+ return {};
+ }
+
+ std::vector<char> Buf(BufSize);
+ if (sysctl(Mib, 3, Buf.data(), &BufSize, nullptr, 0) != 0)
+ {
+ OutEc = MakeErrorCodeFromLastError();
+ return {};
+ }
+
+ // Layout: [int argc][exec_path\0][null padding][argv[0]\0][argv[1]\0]...[envp\0]...
+ if (BufSize < sizeof(int))
+ {
+ return {};
+ }
+ int Argc = 0;
+ memcpy(&Argc, Buf.data(), sizeof(int));
+ if (Argc <= 1)
+ {
+ return {};
+ }
+
+ const char* p = Buf.data() + sizeof(int);
+ const char* End = Buf.data() + BufSize;
+
+ // Skip exec_path and any trailing null padding that follows it
+ while (p < End && *p != '\0')
+ {
+ ++p;
+ }
+ while (p < End && *p == '\0')
+ {
+ ++p;
+ }
+
+ // Skip argv[0]
+ while (p < End && *p != '\0')
+ {
+ ++p;
+ }
+ if (p < End)
+ {
+ ++p;
+ }
+
+ // Collect argv[1..Argc-1]
+ std::string Result;
+ for (int i = 1; i < Argc && p < End; ++i)
+ {
+ if (i > 1)
+ {
+ Result += ' ';
+ }
+ const char* ArgStart = p;
+ while (p < End && *p != '\0')
+ {
+ ++p;
+ }
+ Result.append(ArgStart, p);
+ if (p < End)
+ {
+ ++p;
+ }
+ }
+ return Result;
+
+#else
+ ZEN_UNUSED(Pid);
+ ZEN_UNUSED(OutEc);
+ return {};
+#endif
+}
+
std::error_code
FindProcess(const std::filesystem::path& ExecutableImage, ProcessHandle& OutHandle, bool IncludeSelf)
{
diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp
new file mode 100644
index 000000000..4410d463d
--- /dev/null
+++ b/src/zenutil/consoletui.cpp
@@ -0,0 +1,483 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zenutil/consoletui.h>
+
+#include <zencore/zencore.h>
+
+#if ZEN_PLATFORM_WINDOWS
+# include <zencore/windows.h>
+#else
+# include <poll.h>
+# include <sys/ioctl.h>
+# include <termios.h>
+# include <unistd.h>
+#endif
+
+#include <cstdio>
+
+namespace zen {
+
+//////////////////////////////////////////////////////////////////////////
+// Platform-specific terminal helpers
+
+#if ZEN_PLATFORM_WINDOWS
+
+static bool
+CheckIsInteractiveTerminal()
+{
+ DWORD dwMode = 0;
+ return GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &dwMode) && GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &dwMode);
+}
+
+static void
+EnableVirtualTerminal()
+{
+ HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
+ DWORD dwMode = 0;
+ if (GetConsoleMode(hStdOut, &dwMode))
+ {
+ SetConsoleMode(hStdOut, dwMode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
+ }
+}
+
+// RAII guard: sets the console output code page for the lifetime of the object and
+// restores the original on destruction. Required for UTF-8 glyphs to render correctly
+// via printf/fflush since the default console code page is not UTF-8.
+class ConsoleCodePageGuard
+{
+public:
+ explicit ConsoleCodePageGuard(UINT NewCP) : m_OldCP(GetConsoleOutputCP()) { SetConsoleOutputCP(NewCP); }
+ ~ConsoleCodePageGuard() { SetConsoleOutputCP(m_OldCP); }
+
+private:
+ UINT m_OldCP;
+};
+
+enum class ConsoleKey
+{
+ Unknown,
+ ArrowUp,
+ ArrowDown,
+ Enter,
+ Escape,
+};
+
+static ConsoleKey
+ReadKey()
+{
+ HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
+ INPUT_RECORD Record{};
+ DWORD dwRead = 0;
+ while (true)
+ {
+ if (!ReadConsoleInputA(hStdin, &Record, 1, &dwRead))
+ {
+ return ConsoleKey::Escape; // treat read error as cancel
+ }
+ if (Record.EventType == KEY_EVENT && Record.Event.KeyEvent.bKeyDown)
+ {
+ switch (Record.Event.KeyEvent.wVirtualKeyCode)
+ {
+ case VK_UP:
+ return ConsoleKey::ArrowUp;
+ case VK_DOWN:
+ return ConsoleKey::ArrowDown;
+ case VK_RETURN:
+ return ConsoleKey::Enter;
+ case VK_ESCAPE:
+ return ConsoleKey::Escape;
+ default:
+ break;
+ }
+ }
+ }
+}
+
+#else // POSIX
+
+static bool
+CheckIsInteractiveTerminal()
+{
+ return isatty(STDIN_FILENO) && isatty(STDOUT_FILENO);
+}
+
+static void
+EnableVirtualTerminal()
+{
+ // ANSI escape codes are native on POSIX terminals; nothing to do
+}
+
+// RAII guard: switches the terminal to raw/unbuffered input mode and restores
+// the original attributes on destruction.
+class RawModeGuard
+{
+public:
+ RawModeGuard()
+ {
+ if (tcgetattr(STDIN_FILENO, &m_OldAttrs) != 0)
+ {
+ return;
+ }
+
+ struct termios Raw = m_OldAttrs;
+ Raw.c_iflag &= ~static_cast<tcflag_t>(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
+ Raw.c_cflag |= CS8;
+ Raw.c_lflag &= ~static_cast<tcflag_t>(ECHO | ICANON | IEXTEN | ISIG);
+ Raw.c_cc[VMIN] = 1;
+ Raw.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &Raw) == 0)
+ {
+ m_Valid = true;
+ }
+ }
+
+ ~RawModeGuard()
+ {
+ if (m_Valid)
+ {
+ tcsetattr(STDIN_FILENO, TCSANOW, &m_OldAttrs);
+ }
+ }
+
+ bool IsValid() const { return m_Valid; }
+
+private:
+ struct termios m_OldAttrs = {};
+ bool m_Valid = false;
+};
+
+static int
+ReadByteWithTimeout(int TimeoutMs)
+{
+ struct pollfd Pfd
+ {
+ STDIN_FILENO, POLLIN, 0
+ };
+ if (poll(&Pfd, 1, TimeoutMs) > 0 && (Pfd.revents & POLLIN))
+ {
+ unsigned char c = 0;
+ if (read(STDIN_FILENO, &c, 1) == 1)
+ {
+ return static_cast<int>(c);
+ }
+ }
+ return -1;
+}
+
+// State for fullscreen live mode (alternate screen + raw input)
+static struct termios s_SavedAttrs = {};
+static bool s_InLiveMode = false;
+
+enum class ConsoleKey
+{
+ Unknown,
+ ArrowUp,
+ ArrowDown,
+ Enter,
+ Escape,
+};
+
+static ConsoleKey
+ReadKey()
+{
+ unsigned char c = 0;
+ if (read(STDIN_FILENO, &c, 1) != 1)
+ {
+ return ConsoleKey::Escape; // treat read error as cancel
+ }
+
+ if (c == 27) // ESC byte or start of an escape sequence
+ {
+ int Next = ReadByteWithTimeout(50);
+ if (Next == '[')
+ {
+ int Final = ReadByteWithTimeout(50);
+ if (Final == 'A')
+ {
+ return ConsoleKey::ArrowUp;
+ }
+ if (Final == 'B')
+ {
+ return ConsoleKey::ArrowDown;
+ }
+ }
+ return ConsoleKey::Escape;
+ }
+
+ if (c == '\r' || c == '\n')
+ {
+ return ConsoleKey::Enter;
+ }
+
+ return ConsoleKey::Unknown;
+}
+
+#endif // ZEN_PLATFORM_WINDOWS / POSIX
+
+//////////////////////////////////////////////////////////////////////////
+// Public API
+
+uint32_t
+TuiConsoleColumns(uint32_t Default)
+{
+#if ZEN_PLATFORM_WINDOWS
+ CONSOLE_SCREEN_BUFFER_INFO Csbi = {};
+ if (GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &Csbi))
+ {
+ return static_cast<uint32_t>(Csbi.dwSize.X);
+ }
+#else
+ struct winsize Ws = {};
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &Ws) == 0 && Ws.ws_col > 0)
+ {
+ return static_cast<uint32_t>(Ws.ws_col);
+ }
+#endif
+ return Default;
+}
+
+void
+TuiEnableOutput()
+{
+ EnableVirtualTerminal();
+#if ZEN_PLATFORM_WINDOWS
+ SetConsoleOutputCP(CP_UTF8);
+#endif
+}
+
+bool
+TuiIsStdoutTty()
+{
+#if ZEN_PLATFORM_WINDOWS
+ static bool Cached = [] {
+ DWORD dwMode = 0;
+ return GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &dwMode) != 0;
+ }();
+ return Cached;
+#else
+ static bool Cached = isatty(STDOUT_FILENO) != 0;
+ return Cached;
+#endif
+}
+
+bool
+IsTuiAvailable()
+{
+ static bool Cached = CheckIsInteractiveTerminal();
+ return Cached;
+}
+
+int
+TuiPickOne(std::string_view Title, std::span<const std::string> Items)
+{
+ EnableVirtualTerminal();
+
+#if ZEN_PLATFORM_WINDOWS
+ ConsoleCodePageGuard CodePageGuard(CP_UTF8);
+#else
+ RawModeGuard RawMode;
+ if (!RawMode.IsValid())
+ {
+ return -1;
+ }
+#endif
+
+ const int Count = static_cast<int>(Items.size());
+ int SelectedIndex = 0;
+
+ printf("\n%.*s\n\n", static_cast<int>(Title.size()), Title.data());
+
+ // Hide cursor during interaction
+ printf("\033[?25l");
+
+ // Renders the full entry list and hint footer.
+ // On subsequent calls, moves the cursor back up first to overwrite the previous output.
+ bool FirstRender = true;
+ auto RenderAll = [&] {
+ if (!FirstRender)
+ {
+ printf("\033[%dA", Count + 2); // move up: entries + blank line + hint line
+ }
+ FirstRender = false;
+
+ for (int i = 0; i < Count; ++i)
+ {
+ bool IsSelected = (i == SelectedIndex);
+
+ printf("\r\033[K"); // erase line
+
+ if (IsSelected)
+ {
+ printf("\033[1;7m"); // bold + reverse video
+ }
+
+ // \xe2\x96\xb6 = U+25B6 BLACK RIGHT-POINTING TRIANGLE (▶)
+ const char* Indicator = IsSelected ? " \xe2\x96\xb6 " : " ";
+
+ printf("%s%s", Indicator, Items[i].c_str());
+
+ if (IsSelected)
+ {
+ printf("\033[0m"); // reset attributes
+ }
+
+ printf("\n");
+ }
+
+ // Blank separator line
+ printf("\r\033[K\n");
+
+ // Hint footer
+ // \xe2\x86\x91 = U+2191 ↑ \xe2\x86\x93 = U+2193 ↓
+ printf(
+ "\r\033[K \033[2m\xe2\x86\x91/\xe2\x86\x93\033[0m navigate "
+ "\033[2mEnter\033[0m confirm "
+ "\033[2mEsc\033[0m cancel\n");
+
+ fflush(stdout);
+ };
+
+ RenderAll();
+
+ int Result = -1;
+ bool Done = false;
+ while (!Done)
+ {
+ ConsoleKey Key = ReadKey();
+ switch (Key)
+ {
+ case ConsoleKey::ArrowUp:
+ SelectedIndex = (SelectedIndex - 1 + Count) % Count;
+ RenderAll();
+ break;
+
+ case ConsoleKey::ArrowDown:
+ SelectedIndex = (SelectedIndex + 1) % Count;
+ RenderAll();
+ break;
+
+ case ConsoleKey::Enter:
+ Result = SelectedIndex;
+ Done = true;
+ break;
+
+ case ConsoleKey::Escape:
+ Done = true;
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ // Restore cursor and add a blank line for visual separation
+ printf("\033[?25h\n");
+ fflush(stdout);
+
+ return Result;
+}
+
+void
+TuiEnterAlternateScreen()
+{
+ EnableVirtualTerminal();
+#if ZEN_PLATFORM_WINDOWS
+ SetConsoleOutputCP(CP_UTF8);
+#endif
+
+ printf("\033[?1049h"); // Enter alternate screen buffer
+ printf("\033[?25l"); // Hide cursor
+ fflush(stdout);
+
+#if !ZEN_PLATFORM_WINDOWS
+ if (tcgetattr(STDIN_FILENO, &s_SavedAttrs) == 0)
+ {
+ struct termios Raw = s_SavedAttrs;
+ Raw.c_iflag &= ~static_cast<tcflag_t>(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
+ Raw.c_cflag |= CS8;
+ Raw.c_lflag &= ~static_cast<tcflag_t>(ECHO | ICANON | IEXTEN | ISIG);
+ Raw.c_cc[VMIN] = 1;
+ Raw.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &Raw) == 0)
+ {
+ s_InLiveMode = true;
+ }
+ }
+#endif
+}
+
+void
+TuiExitAlternateScreen()
+{
+ printf("\033[?25h"); // Show cursor
+ printf("\033[?1049l"); // Exit alternate screen buffer
+ fflush(stdout);
+
+#if !ZEN_PLATFORM_WINDOWS
+ if (s_InLiveMode)
+ {
+ tcsetattr(STDIN_FILENO, TCSANOW, &s_SavedAttrs);
+ s_InLiveMode = false;
+ }
+#endif
+}
+
+void
+TuiCursorHome()
+{
+ printf("\033[H");
+}
+
+uint32_t
+TuiConsoleRows(uint32_t Default)
+{
+#if ZEN_PLATFORM_WINDOWS
+ CONSOLE_SCREEN_BUFFER_INFO Csbi = {};
+ if (GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &Csbi))
+ {
+ return static_cast<uint32_t>(Csbi.srWindow.Bottom - Csbi.srWindow.Top + 1);
+ }
+#else
+ struct winsize Ws = {};
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &Ws) == 0 && Ws.ws_row > 0)
+ {
+ return static_cast<uint32_t>(Ws.ws_row);
+ }
+#endif
+ return Default;
+}
+
+bool
+TuiPollQuit()
+{
+#if ZEN_PLATFORM_WINDOWS
+ HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
+ DWORD dwCount = 0;
+ if (!GetNumberOfConsoleInputEvents(hStdin, &dwCount) || dwCount == 0)
+ {
+ return false;
+ }
+ INPUT_RECORD Record{};
+ DWORD dwRead = 0;
+ while (PeekConsoleInputA(hStdin, &Record, 1, &dwRead) && dwRead > 0)
+ {
+ ReadConsoleInputA(hStdin, &Record, 1, &dwRead);
+ if (Record.EventType == KEY_EVENT && Record.Event.KeyEvent.bKeyDown)
+ {
+ WORD vk = Record.Event.KeyEvent.wVirtualKeyCode;
+ char ch = Record.Event.KeyEvent.uChar.AsciiChar;
+ if (vk == VK_ESCAPE || ch == 'q' || ch == 'Q')
+ {
+ return true;
+ }
+ }
+ }
+ return false;
+#else
+ // Non-blocking read: character 3 = Ctrl+C, 27 = Esc, 'q'/'Q' = quit
+ int b = ReadByteWithTimeout(0);
+ return (b == 3 || b == 27 || b == 'q' || b == 'Q');
+#endif
+}
+
+} // namespace zen
diff --git a/src/zenutil/include/zenutil/consoletui.h b/src/zenutil/include/zenutil/consoletui.h
new file mode 100644
index 000000000..7dc68c126
--- /dev/null
+++ b/src/zenutil/include/zenutil/consoletui.h
@@ -0,0 +1,59 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include <span>
+#include <string>
+#include <string_view>
+
+namespace zen {
+
+// Returns the width of the console in columns, or Default if it cannot be determined.
+uint32_t TuiConsoleColumns(uint32_t Default = 120);
+
+// Enables ANSI/VT escape code processing and UTF-8 console output.
+// Call once before printing ANSI escape sequences or multi-byte UTF-8 characters via printf.
+// Safe to call multiple times. No-op on POSIX (escape codes are native there).
+void TuiEnableOutput();
+
+// Returns true if stdout is connected to a real terminal (not piped or redirected).
+// Useful for deciding whether to use ANSI escape codes for progress output.
+bool TuiIsStdoutTty();
+
+// Returns true if both stdin and stdout are connected to an interactive terminal
+// (i.e. not piped or redirected). Must be checked before calling TuiPickOne().
+bool IsTuiAvailable();
+
+// Displays a cursor-navigable single-select list in the terminal.
+//
+// - Title: a short description printed once above the list
+// - Items: pre-formatted display labels, one per selectable entry
+//
+// Arrow keys (↑/↓) navigate the selection, Enter confirms, Esc cancels.
+// Returns the index of the selected item, or -1 if the user cancelled.
+//
+// Precondition: IsTuiAvailable() must be true.
+int TuiPickOne(std::string_view Title, std::span<const std::string> Items);
+
+// Enter the alternate screen buffer for fullscreen live-update mode.
+// Hides the cursor. On POSIX, switches to raw/unbuffered terminal input.
+// Must be balanced by a call to TuiExitAlternateScreen().
+// Precondition: IsTuiAvailable() must be true.
+void TuiEnterAlternateScreen();
+
+// Exit alternate screen buffer. Restores the cursor and, on POSIX, the original
+// terminal mode. Safe to call even if TuiEnterAlternateScreen() was not called.
+void TuiExitAlternateScreen();
+
+// Move the cursor to the top-left corner of the terminal (row 1, col 1).
+void TuiCursorHome();
+
+// Returns the height of the console in rows, or Default if it cannot be determined.
+uint32_t TuiConsoleRows(uint32_t Default = 40);
+
+// Non-blocking check: returns true if the user has pressed a key that means quit
+// (Esc, 'q', 'Q', or Ctrl+C). Consumes the event if one is pending.
+// Should only be called while in alternate screen mode.
+bool TuiPollQuit();
+
+} // namespace zen