aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-02-25 19:12:09 +0100
committerStefan Boberg <[email protected]>2026-02-25 19:12:09 +0100
commit95c7ae6bdc71e1451b9a5073591634036852f359 (patch)
tree4cfb956ffb17939d7b5962abc4e1361efeab42bb
parentupdated CLAUDE.md (diff)
downloadzen-95c7ae6bdc71e1451b9a5073591634036852f359.tar.xz
zen-95c7ae6bdc71e1451b9a5073591634036852f359.zip
initial top command sketch
-rw-r--r--src/zen/cmds/top_cmd.cpp513
-rw-r--r--src/zen/cmds/top_cmd.h2
-rw-r--r--src/zenutil/consoletui.cpp83
-rw-r--r--src/zenutil/include/zenutil/consoletui.h24
4 files changed, 575 insertions, 47 deletions
diff --git a/src/zen/cmds/top_cmd.cpp b/src/zen/cmds/top_cmd.cpp
index f674db6cd..0d74e96be 100644
--- a/src/zen/cmds/top_cmd.cpp
+++ b/src/zen/cmds/top_cmd.cpp
@@ -2,20 +2,186 @@
#include "top_cmd.h"
+#include <zencore/compactbinary.h>
#include <zencore/fmtutils.h>
#include <zencore/logging.h>
+#include <zencore/scopeguard.h>
+#include <zencore/string.h>
#include <zencore/system.h>
+#include <zencore/thread.h>
#include <zencore/uid.h>
+#include <zenhttp/httpclient.h>
+#include <zenutil/consoletui.h>
#include <zenutil/zenserverprocess.h>
+#include <chrono>
+#include <ctime>
#include <memory>
//////////////////////////////////////////////////////////////////////////
namespace zen {
+namespace {
+
+ struct RunningInstance
+ {
+ uint16_t Port;
+ uint32_t Pid;
+ std::string SessionId;
+ };
+
+ static std::vector<RunningInstance> CollectInstances(ZenServerState& State)
+ {
+ std::vector<RunningInstance> Instances;
+ State.Snapshot([&](const ZenServerState::ZenServerEntry& Entry) {
+ StringBuilder<25> SessionSB;
+ Entry.GetSessionId().ToString(SessionSB);
+ Instances.push_back({Entry.EffectiveListenPort.load(), Entry.Pid.load(), std::string(SessionSB.c_str())});
+ });
+ return Instances;
+ }
+
+ static std::string FormatCurrentTime()
+ {
+ std::time_t Now = std::time(nullptr);
+ std::tm Local{};
+#if ZEN_PLATFORM_WINDOWS
+ localtime_s(&Local, &Now);
+#else
+ localtime_r(&Now, &Local);
+#endif
+ char Buffer[20];
+ std::strftime(Buffer, sizeof(Buffer), "%Y-%m-%d %H:%M:%S", &Local);
+ return Buffer;
+ }
+
+ static void PrintHorizontalRule(uint32_t Cols)
+ {
+ // U+2500 BOX DRAWINGS LIGHT HORIZONTAL = \xe2\x94\x80 (3 bytes per glyph)
+ ExtendableStringBuilder<512> Line;
+ for (uint32_t i = 0; i < Cols; ++i)
+ {
+ Line.Append("\xe2\x94\x80");
+ }
+ printf("%s\n", Line.c_str());
+ }
+
+ struct ServerStats
+ {
+ // /health/info
+ bool InfoAvailable = false;
+ std::string_view DataRoot;
+ std::string_view BuildVersion;
+
+ // /stats/z$ -> cache
+ bool CacheAvailable = false;
+ uint64_t CacheHits = 0;
+ uint64_t CacheMisses = 0;
+ double CacheHitRatio = 0.0;
+ uint64_t CacheWrites = 0;
+ uint64_t CacheDiskSize = 0;
+ uint64_t CacheMemSize = 0;
+ uint64_t CacheRpcCount = 0;
+ uint64_t CacheRpcOps = 0;
+
+ // /stats/builds -> store
+ bool BuildsAvailable = false;
+ uint64_t BlobReadCount = 0;
+ uint64_t BlobWriteCount = 0;
+ uint64_t BlobHitCount = 0;
+ uint64_t BuildDiskSize = 0;
+
+ // Owned buffers to keep string_views alive
+ CbObject InfoObject;
+ CbObject CacheObject;
+ CbObject BuildsObject;
+ };
+
+ static void FetchServerStats(HttpClient& Http, ServerStats& Stats)
+ {
+ // Fetch /health/info
+ try
+ {
+ if (HttpClient::Response Response = Http.Get("/health/info"))
+ {
+ Stats.InfoObject = Response.AsObject();
+ Stats.DataRoot = Stats.InfoObject["DataRoot"].AsString();
+ Stats.BuildVersion = Stats.InfoObject["BuildVersion"].AsString();
+ Stats.InfoAvailable = true;
+ }
+ }
+ catch (...)
+ {
+ }
+
+ // Fetch /stats/z$
+ try
+ {
+ if (HttpClient::Response Response = Http.Get("/stats/z$"))
+ {
+ Stats.CacheObject = Response.AsObject();
+ CbObjectView CacheView = Stats.CacheObject["cache"].AsObjectView();
+ Stats.CacheHits = CacheView["hits"].AsUInt64(0);
+ Stats.CacheMisses = CacheView["misses"].AsUInt64(0);
+ Stats.CacheHitRatio = CacheView["hit_ratio"].AsDouble(0.0);
+ Stats.CacheWrites = CacheView["writes"].AsUInt64(0);
+
+ CbObjectView SizeView = CacheView["size"].AsObjectView();
+ Stats.CacheDiskSize = SizeView["disk"].AsUInt64(0);
+ Stats.CacheMemSize = SizeView["memory"].AsUInt64(0);
+
+ CbObjectView RpcView = CacheView["rpc"].AsObjectView();
+ Stats.CacheRpcCount = RpcView["count"].AsUInt64(0);
+ Stats.CacheRpcOps = RpcView["ops"].AsUInt64(0);
+
+ Stats.CacheAvailable = true;
+ }
+ }
+ catch (...)
+ {
+ }
+
+ // Fetch /stats/builds
+ try
+ {
+ if (HttpClient::Response Response = Http.Get("/stats/builds"))
+ {
+ Stats.BuildsObject = Response.AsObject();
+ CbObjectView StoreView = Stats.BuildsObject["store"].AsObjectView();
+
+ CbObjectView BlobView = StoreView["blobs"].AsObjectView();
+ Stats.BlobReadCount = BlobView["readcount"].AsUInt64(0);
+ Stats.BlobWriteCount = BlobView["writecount"].AsUInt64(0);
+ Stats.BlobHitCount = BlobView["hitcount"].AsUInt64(0);
+
+ CbObjectView BuildSizeView = StoreView["size"].AsObjectView();
+ Stats.BuildDiskSize = BuildSizeView["disk"].AsUInt64(0);
+
+ Stats.BuildsAvailable = true;
+ }
+ }
+ catch (...)
+ {
+ }
+ }
+
+ static HttpClientSettings MakeQuickSettings()
+ {
+ HttpClientSettings Settings;
+ Settings.ConnectTimeout = std::chrono::milliseconds(2000);
+ Settings.Timeout = std::chrono::milliseconds(3000);
+ return Settings;
+ }
+
+} // namespace
+
TopCommand::TopCommand()
{
+ m_Options.add_options()("h,help", "Print help");
+ m_Options.add_option("", "u", "hosturl", "Host URL", cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+ m_Options
+ .add_option("", "i", "interval", "Refresh interval in milliseconds", cxxopts::value(m_IntervalMs)->default_value("1000"), "<ms>");
}
TopCommand::~TopCommand() = default;
@@ -23,79 +189,332 @@ TopCommand::~TopCommand() = default;
void
TopCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
{
- ZEN_UNUSED(GlobalOptions, argc, argv);
+ ZEN_UNUSED(GlobalOptions);
- SystemMetrics Metrics = GetSystemMetrics();
-
- struct SystemMetric
+ if (!ParseOptions(argc, argv))
{
- const char* Name;
- uint64_t Value;
- } MetricValues[] = {
- {"Cpus", Metrics.CpuCount},
- {"Cores", Metrics.CoreCount},
- {"LPs", Metrics.LogicalProcessorCount},
- {"SysMemMiB", Metrics.SystemMemoryMiB},
- {"AvailSysMemMiB", Metrics.AvailSystemMemoryMiB},
- {"VirtMemMiB", Metrics.VirtualMemoryMiB},
- {"AvailVirtMemMiB", Metrics.AvailVirtualMemoryMiB},
- {"PageFileMiB", Metrics.PageFileMiB},
- {"AvailPageFileMiB", Metrics.AvailPageFileMiB},
- };
+ return;
+ }
- std::vector<size_t> ColumnWidths;
- for (const SystemMetric& Metric : MetricValues)
+ if (m_IntervalMs < 100)
{
- size_t NameLen = strlen(Metric.Name);
- size_t ValueLen = fmt::formatted_size("{}", Metric.Value);
- ColumnWidths.push_back(std::max(NameLen, ValueLen));
+ m_IntervalMs = 100;
}
- ExtendableStringBuilder<128> Header;
- for (size_t i = 0; i < sizeof(MetricValues) / sizeof(MetricValues[0]); ++i)
+ // Gather system metrics
+ SystemMetrics SysMetrics = GetSystemMetrics();
+
+ // Gather running instances
+ ZenServerState State;
+ bool HasState = State.InitializeReadOnly();
+
+ // Collect instances and determine initial target
+ std::vector<RunningInstance> Instances;
+ if (HasState)
{
- Header << fmt::format("{:<{}} ", MetricValues[i].Name, ColumnWidths[i]);
+ Instances = CollectInstances(State);
}
- ZEN_CONSOLE("{}", Header);
- // Print values with same adaptive widths
- ExtendableStringBuilder<128> Values;
- for (size_t i = 0; i < sizeof(MetricValues) / sizeof(MetricValues[0]); ++i)
+ // Resolve the host to connect to. If -u was given, use that directly.
+ // Otherwise auto-discover from running instances.
+ std::string ActiveHost = m_HostName;
+ int SelectedIdx = -1; // index into Instances; -1 = explicit host or none
+
+ if (ActiveHost.empty() && !Instances.empty())
{
- Values << fmt::format("{:>{}} ", MetricValues[i].Value, ColumnWidths[i]);
+ SelectedIdx = 0;
+ ActiveHost = fmt::format("http://localhost:{}", Instances[0].Port);
+ }
+ else if (!ActiveHost.empty())
+ {
+ ActiveHost = ResolveTargetHostSpec(ActiveHost);
}
- ZEN_CONSOLE("{}\n", Values);
- ZenServerState State;
- if (!State.InitializeReadOnly())
+ // If not an interactive terminal, print a single snapshot and exit
+ if (!IsTuiAvailable())
{
- ZEN_CONSOLE("No Zen state found");
+ printf("zen top");
+ if (!ActiveHost.empty())
+ {
+ printf(" - %s", ActiveHost.c_str());
+ }
+ printf(" - %s\n", FormatCurrentTime().c_str());
+
+ printf("System: %u CPU, %u cores, %u LPs | Memory: %llu MiB (%llu avail)\n",
+ SysMetrics.CpuCount,
+ SysMetrics.CoreCount,
+ SysMetrics.LogicalProcessorCount,
+ (unsigned long long)SysMetrics.SystemMemoryMiB,
+ (unsigned long long)SysMetrics.AvailSystemMemoryMiB);
+
+ if (!Instances.empty())
+ {
+ printf("\nInstances:\n");
+ printf(" %5s %7s %s\n", "PORT", "PID", "SESSION");
+ for (const auto& Inst : Instances)
+ {
+ printf(" %5u %7u %s\n", Inst.Port, Inst.Pid, Inst.SessionId.c_str());
+ }
+ }
+
+ if (!ActiveHost.empty())
+ {
+ HttpClient Http(ActiveHost, MakeQuickSettings());
+ ServerStats Stats;
+ FetchServerStats(Http, Stats);
+
+ if (Stats.CacheAvailable)
+ {
+ printf("\nCache (z$):\n");
+ printf(" Hits: %llu Misses: %llu Hit Ratio: %.1f%% Writes: %llu\n",
+ (unsigned long long)Stats.CacheHits,
+ (unsigned long long)Stats.CacheMisses,
+ Stats.CacheHitRatio * 100.0,
+ (unsigned long long)Stats.CacheWrites);
+ printf(" Disk: %s Memory: %s\n", NiceBytes(Stats.CacheDiskSize).c_str(), NiceBytes(Stats.CacheMemSize).c_str());
+ printf(" RPC: %llu requests, %llu ops\n", (unsigned long long)Stats.CacheRpcCount, (unsigned long long)Stats.CacheRpcOps);
+ }
+
+ if (Stats.BuildsAvailable)
+ {
+ printf("\nBuild Store:\n");
+ printf(" Blobs: %llu reads, %llu writes, %llu hits | %s on disk\n",
+ (unsigned long long)Stats.BlobReadCount,
+ (unsigned long long)Stats.BlobWriteCount,
+ (unsigned long long)Stats.BlobHitCount,
+ NiceBytes(Stats.BuildDiskSize).c_str());
+ }
+ }
return;
}
- int n = 0;
- const int HeaderPeriod = 20;
+ // Enter fullscreen alternate-screen mode
+ TuiEnterAlternateScreen();
+ auto ExitGuard = MakeGuard([]() { TuiExitAlternateScreen(); });
+
+ // Create HTTP client for the active host
+ std::unique_ptr<HttpClient> Http;
+ auto RecreateClient = [&]() {
+ if (!ActiveHost.empty())
+ {
+ Http = std::make_unique<HttpClient>(ActiveHost, MakeQuickSettings());
+ }
+ else
+ {
+ Http.reset();
+ }
+ };
+ RecreateClient();
+
+ bool NeedsRedraw = true;
+ // Main refresh loop
for (;;)
{
- if ((n++ % HeaderPeriod) == 0)
+ if (NeedsRedraw)
{
- ZEN_CONSOLE("{:>5} {:>6} {:>24}", "port", "pid", "session");
- }
+ uint32_t Cols = TuiConsoleColumns();
- State.Snapshot([&](const ZenServerState::ZenServerEntry& Entry) {
- StringBuilder<25> SessionStringBuilder;
- Entry.GetSessionId().ToString(SessionStringBuilder);
- ZEN_CONSOLE("{:>5} {:>6} {:>24}", Entry.EffectiveListenPort.load(), Entry.Pid.load(), SessionStringBuilder);
- });
+ TuiCursorHome();
+
+ // Fetch fresh system metrics each iteration
+ SysMetrics = GetSystemMetrics();
+
+ // Refresh instance list
+ if (HasState)
+ {
+ if (!State.IsReadOnly())
+ {
+ State.Sweep();
+ }
+ Instances = CollectInstances(State);
+
+ // Clamp selection
+ if (SelectedIdx >= static_cast<int>(Instances.size()))
+ {
+ SelectedIdx = static_cast<int>(Instances.size()) - 1;
+ }
+ }
+
+ // Fetch server stats
+ ServerStats Stats;
+ if (Http)
+ {
+ FetchServerStats(*Http, Stats);
+ }
+
+ // Header line
+ {
+ ExtendableStringBuilder<256> Header;
+ Header << "zen top";
+ if (!ActiveHost.empty())
+ {
+ Header << " - " << ActiveHost;
+ }
+ if (Stats.InfoAvailable && !Stats.BuildVersion.empty())
+ {
+ Header << " - " << Stats.BuildVersion;
+ }
+ Header << " - " << FormatCurrentTime();
+ printf("%-*s\n", Cols, Header.c_str());
+ }
- zen::Sleep(1000);
+ PrintHorizontalRule(Cols);
- if (!State.IsReadOnly())
+ // System metrics
+ printf("System: %u CPU, %u cores, %u LPs | Memory: %llu MiB (%llu avail)\033[K\n",
+ SysMetrics.CpuCount,
+ SysMetrics.CoreCount,
+ SysMetrics.LogicalProcessorCount,
+ (unsigned long long)SysMetrics.SystemMemoryMiB,
+ (unsigned long long)SysMetrics.AvailSystemMemoryMiB);
+
+ // Running instances
+ printf("\033[K\n");
+ if (!Instances.empty())
+ {
+ bool MultipleInstances = (Instances.size() > 1 && SelectedIdx >= 0);
+ if (MultipleInstances)
+ {
+ printf("\033[1mInstances:\033[0m \033[2m(\xe2\x86\x91/\xe2\x86\x93 to switch)\033[0m\033[K\n");
+ }
+ else
+ {
+ printf("\033[1mInstances:\033[0m\033[K\n");
+ }
+ printf(" %5s %7s %s\033[K\n", "PORT", "PID", "SESSION");
+ for (int i = 0; i < static_cast<int>(Instances.size()); ++i)
+ {
+ const auto& Inst = Instances[i];
+ bool IsSelected = (i == SelectedIdx);
+ if (IsSelected)
+ {
+ // Highlight: bold + reverse video
+ printf("\033[1;7m");
+ }
+ printf(" %5u %7u %s\033[K", Inst.Port, Inst.Pid, Inst.SessionId.c_str());
+ if (IsSelected)
+ {
+ printf("\033[0m");
+ }
+ printf("\n");
+ }
+ }
+ else
+ {
+ printf(" No Zen instances found\033[K\n");
+ }
+
+ // Cache stats
+ printf("\033[K\n");
+ if (Stats.CacheAvailable)
+ {
+ printf("\033[1mCache (z$):\033[0m\033[K\n");
+ printf(" Hits: %-10llu Misses: %-10llu Hit Ratio: %-7.1f%% Writes: %llu\033[K\n",
+ (unsigned long long)Stats.CacheHits,
+ (unsigned long long)Stats.CacheMisses,
+ Stats.CacheHitRatio * 100.0,
+ (unsigned long long)Stats.CacheWrites);
+ printf(" Disk: %-12s Memory: %s\033[K\n", NiceBytes(Stats.CacheDiskSize).c_str(), NiceBytes(Stats.CacheMemSize).c_str());
+ printf(" RPC: %llu requests, %llu ops\033[K\n",
+ (unsigned long long)Stats.CacheRpcCount,
+ (unsigned long long)Stats.CacheRpcOps);
+ }
+ else if (Http)
+ {
+ printf("\033[1mCache (z$):\033[0m unavailable\033[K\n");
+ }
+
+ // Build store stats
+ printf("\033[K\n");
+ if (Stats.BuildsAvailable)
+ {
+ printf("\033[1mBuild Store:\033[0m\033[K\n");
+ printf(" Blobs: %llu reads, %llu writes, %llu hits | %s on disk\033[K\n",
+ (unsigned long long)Stats.BlobReadCount,
+ (unsigned long long)Stats.BlobWriteCount,
+ (unsigned long long)Stats.BlobHitCount,
+ NiceBytes(Stats.BuildDiskSize).c_str());
+ }
+ else if (Http)
+ {
+ printf("\033[1mBuild Store:\033[0m unavailable\033[K\n");
+ }
+
+ // Server info
+ if (Stats.InfoAvailable && !Stats.DataRoot.empty())
+ {
+ printf("\033[K\n");
+ printf(" Data root: %.*s\033[K\n", static_cast<int>(Stats.DataRoot.size()), Stats.DataRoot.data());
+ }
+
+ // Clear any stale content below
+ TuiClearToBottom();
+
+ // Footer (print at bottom by moving cursor)
+ {
+ uint32_t Rows = TuiConsoleRows();
+ printf("\033[%u;1H", Rows); // Move to last row
+ if (Instances.size() > 1 && SelectedIdx >= 0)
+ {
+ printf("\033[2mq quit | \xe2\x86\x91/\xe2\x86\x93 switch instance | Refreshing every %.1fs\033[0m\033[K",
+ static_cast<double>(m_IntervalMs) / 1000.0);
+ }
+ else
+ {
+ printf("\033[2mPress q to quit | Refreshing every %.1fs\033[0m\033[K", static_cast<double>(m_IntervalMs) / 1000.0);
+ }
+ }
+
+ fflush(stdout);
+ NeedsRedraw = false;
+ }
+
+ // Wait for input or next tick, whichever comes first.
+ // Uses TuiWaitForInput() which blocks on the OS input handle, so keypresses
+ // are detected immediately rather than waiting for a sleep interval to elapse.
+ auto TickStart = std::chrono::steady_clock::now();
+ for (;;)
{
- State.Sweep();
+ TuiInput Input = TuiPollInput();
+ if (Input == TuiInput::Quit)
+ {
+ return;
+ }
+ if (Input == TuiInput::ArrowUp || Input == TuiInput::ArrowDown)
+ {
+ // Only handle arrow keys when we have auto-discovered instances to switch between
+ if (SelectedIdx >= 0 && Instances.size() > 1)
+ {
+ int Count = static_cast<int>(Instances.size());
+ if (Input == TuiInput::ArrowUp)
+ {
+ SelectedIdx = (SelectedIdx - 1 + Count) % Count;
+ }
+ else
+ {
+ SelectedIdx = (SelectedIdx + 1) % Count;
+ }
+ ActiveHost = fmt::format("http://localhost:{}", Instances[SelectedIdx].Port);
+ RecreateClient();
+ NeedsRedraw = true;
+ break;
+ }
+ }
+
+ auto Now = std::chrono::steady_clock::now();
+ uint32_t ElapsedMs = static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::milliseconds>(Now - TickStart).count());
+ if (ElapsedMs >= m_IntervalMs)
+ {
+ break;
+ }
+
+ TuiWaitForInput(m_IntervalMs - ElapsedMs);
}
+
+ // Schedule a full redraw for the next iteration
+ NeedsRedraw = true;
}
}
diff --git a/src/zen/cmds/top_cmd.h b/src/zen/cmds/top_cmd.h
index aeb196558..2e4012a57 100644
--- a/src/zen/cmds/top_cmd.h
+++ b/src/zen/cmds/top_cmd.h
@@ -20,6 +20,8 @@ public:
private:
cxxopts::Options m_Options{Name, Description};
+ std::string m_HostName;
+ uint32_t m_IntervalMs = 1000;
};
class PsCommand : public ZenCmdBase
diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp
index 4410d463d..777819224 100644
--- a/src/zenutil/consoletui.cpp
+++ b/src/zenutil/consoletui.cpp
@@ -480,4 +480,87 @@ TuiPollQuit()
#endif
}
+TuiInput
+TuiPollInput()
+{
+#if ZEN_PLATFORM_WINDOWS
+ HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
+ DWORD dwCount = 0;
+ if (!GetNumberOfConsoleInputEvents(hStdin, &dwCount) || dwCount == 0)
+ {
+ return TuiInput::None;
+ }
+ 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 TuiInput::Quit;
+ }
+ if (vk == VK_UP)
+ {
+ return TuiInput::ArrowUp;
+ }
+ if (vk == VK_DOWN)
+ {
+ return TuiInput::ArrowDown;
+ }
+ }
+ }
+ return TuiInput::None;
+#else
+ int b = ReadByteWithTimeout(0);
+ if (b == 3 || b == 'q' || b == 'Q')
+ {
+ return TuiInput::Quit;
+ }
+ if (b == 27)
+ {
+ // Could be bare Esc or start of an escape sequence (e.g. \033[A)
+ int Next = ReadByteWithTimeout(50);
+ if (Next == '[')
+ {
+ int Final = ReadByteWithTimeout(50);
+ if (Final == 'A')
+ {
+ return TuiInput::ArrowUp;
+ }
+ if (Final == 'B')
+ {
+ return TuiInput::ArrowDown;
+ }
+ }
+ return TuiInput::Quit; // bare Esc
+ }
+ return TuiInput::None;
+#endif
+}
+
+bool
+TuiWaitForInput(uint32_t TimeoutMs)
+{
+#if ZEN_PLATFORM_WINDOWS
+ HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
+ return WaitForSingleObject(hStdin, TimeoutMs) == WAIT_OBJECT_0;
+#else
+ struct pollfd Pfd
+ {
+ STDIN_FILENO, POLLIN, 0
+ };
+ return poll(&Pfd, 1, static_cast<int>(TimeoutMs)) > 0;
+#endif
+}
+
+void
+TuiClearToBottom()
+{
+ printf("\033[J");
+}
+
} // namespace zen
diff --git a/src/zenutil/include/zenutil/consoletui.h b/src/zenutil/include/zenutil/consoletui.h
index 7dc68c126..edbcd6862 100644
--- a/src/zenutil/include/zenutil/consoletui.h
+++ b/src/zenutil/include/zenutil/consoletui.h
@@ -56,4 +56,28 @@ uint32_t TuiConsoleRows(uint32_t Default = 40);
// Should only be called while in alternate screen mode.
bool TuiPollQuit();
+// Input events returned by TuiPollInput().
+enum class TuiInput
+{
+ None,
+ Quit, // Esc, 'q', 'Q', Ctrl+C
+ ArrowUp,
+ ArrowDown,
+};
+
+// Non-blocking check: consumes at most one pending input event and returns it.
+// Returns TuiInput::None if no event is pending.
+// Should only be called while in alternate screen mode.
+TuiInput TuiPollInput();
+
+// Blocks until console input is available or the timeout (in milliseconds) expires.
+// Returns true if input is ready to be read, false on timeout.
+// More efficient than polling in a loop with Sleep() -- uses the OS wait primitive
+// (WaitForSingleObject on Windows, poll() on POSIX) so keypresses are detected immediately.
+// Should only be called while in alternate screen mode.
+bool TuiWaitForInput(uint32_t TimeoutMs);
+
+// Erase from the current cursor position to the end of the screen.
+void TuiClearToBottom();
+
} // namespace zen