aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/zen/cmds/help_cmd.cpp185
-rw-r--r--src/zen/cmds/help_cmd.h33
-rw-r--r--src/zen/zen.cpp12
-rw-r--r--src/zen/zen.h7
-rw-r--r--src/zenutil/consoletui.cpp867
-rw-r--r--src/zenutil/include/zenutil/consoletui.h42
6 files changed, 995 insertions, 151 deletions
diff --git a/src/zen/cmds/help_cmd.cpp b/src/zen/cmds/help_cmd.cpp
new file mode 100644
index 000000000..82c260a0e
--- /dev/null
+++ b/src/zen/cmds/help_cmd.cpp
@@ -0,0 +1,185 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "help_cmd.h"
+
+#include <zencore/string.h>
+#include <zenutil/consoletui.h>
+#include <zenutil/logging.h>
+
+#include <algorithm>
+#include <map>
+
+namespace zen {
+
+HelpCommand::HelpCommand()
+{
+ m_Options.add_options()("h,help", "Print help");
+ m_Options.add_options()("l,list", "List all commands (non-interactive)", cxxopts::value(m_List)->default_value("false"));
+ m_Options.add_option("__hidden__", "", "command", "Command to show help for", cxxopts::value(m_CommandName)->default_value(""), "");
+ m_Options.parse_positional({"command"});
+}
+
+HelpCommand::~HelpCommand()
+{
+}
+
+// Split a string into lines, handling \r\n and \n
+static std::vector<std::string>
+SplitLines(const std::string& Text)
+{
+ std::vector<std::string> Lines;
+ size_t Start = 0;
+ while (Start <= Text.size())
+ {
+ size_t End = Text.find('\n', Start);
+ if (End == std::string::npos)
+ {
+ Lines.push_back(Text.substr(Start));
+ break;
+ }
+ size_t LineEnd = (End > 0 && Text[End - 1] == '\r') ? End - 1 : End;
+ Lines.push_back(Text.substr(Start, LineEnd - Start));
+ Start = End + 1;
+ }
+ return Lines;
+}
+
+// Build a display label for a command in the picker list
+static std::string
+FormatPickerLabel(const CommandInfo& Cmd)
+{
+ return fmt::format("{:<20s} {}", Cmd.CmdName, Cmd.CmdSummary);
+}
+
+// Print all commands grouped by category (same format as zen --help)
+static void
+PrintCommandList(std::span<const CommandInfo> Commands)
+{
+ std::map<std::string, ZenCmdCategory*> Categories;
+
+ for (const CommandInfo& CmdInfo : Commands)
+ {
+ ZenCmdCategory& Category = CmdInfo.Cmd->CommandCategory();
+ Categories[Category.Name] = &Category;
+ Category.SortedCmds[CmdInfo.CmdName] = CmdInfo.CmdSummary;
+ }
+
+ ZEN_CONSOLE("available commands:\n");
+
+ for (const auto& [CategoryName, Category] : Categories)
+ {
+ ZEN_CONSOLE(" {}\n", CategoryName);
+
+ for (const auto& [Name, Summary] : Category->SortedCmds)
+ {
+ ZEN_CONSOLE(" {:<20s} {}", Name, Summary);
+ }
+
+ ZEN_CONSOLE("");
+ }
+}
+
+void
+HelpCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
+{
+ ZEN_UNUSED(GlobalOptions);
+
+ if (!ParseOptions(argc, argv))
+ {
+ return;
+ }
+
+ // zen help --list: print plain listing
+ if (m_List)
+ {
+ PrintCommandList(m_Commands);
+ return;
+ }
+
+ // zen help <command>: show help for a specific command
+ if (!m_CommandName.empty())
+ {
+ for (const CommandInfo& Cmd : m_Commands)
+ {
+ if (StrCaseCompare(m_CommandName.c_str(), Cmd.CmdName) == 0)
+ {
+ std::string HelpText = Cmd.Cmd->HelpText();
+
+ if (IsTuiAvailable())
+ {
+ std::vector<std::string> Lines = SplitLines(HelpText);
+ std::string Title = fmt::format("zen {} -- {}", Cmd.CmdName, Cmd.CmdSummary);
+ TuiPager(Title, Lines);
+ }
+ else
+ {
+ ZEN_CONSOLE("{}", HelpText);
+ }
+ return;
+ }
+ }
+
+ ZEN_CONSOLE_ERROR("Unknown command: '{}'", m_CommandName);
+ ZEN_CONSOLE("");
+ PrintCommandList(m_Commands);
+ return;
+ }
+
+ // zen help (no args): interactive browser
+ if (!IsTuiAvailable())
+ {
+ PrintCommandList(m_Commands);
+ return;
+ }
+
+ // Build picker items grouped by category
+ std::map<std::string, std::vector<const CommandInfo*>> ByCategory;
+ for (const CommandInfo& Cmd : m_Commands)
+ {
+ ByCategory[Cmd.Cmd->CommandCategory().Name].push_back(&Cmd);
+ }
+
+ // Flatten into picker labels, keeping track of which CommandInfo each label maps to
+ std::vector<std::string> Labels;
+ std::vector<const CommandInfo*> LabelCmds;
+
+ for (const auto& [CategoryName, Cmds] : ByCategory)
+ {
+ // Category header (not selectable — we'll skip it by index)
+ Labels.push_back(fmt::format(" -- {} --", CategoryName));
+ LabelCmds.push_back(nullptr);
+
+ for (const CommandInfo* Cmd : Cmds)
+ {
+ Labels.push_back(FormatPickerLabel(*Cmd));
+ LabelCmds.push_back(Cmd);
+ }
+ }
+
+ // Loop: pick a command, show its help, then return to the picker at the same position
+ int LastSelected = 0;
+ std::string PickerFilter;
+ while (true)
+ {
+ int Selected = TuiPickOne("Select a command to view its help:", Labels, LastSelected, &PickerFilter);
+ if (Selected < 0)
+ {
+ return; // User cancelled
+ }
+
+ LastSelected = Selected;
+
+ const CommandInfo* SelectedCmd = LabelCmds[static_cast<size_t>(Selected)];
+ if (SelectedCmd == nullptr)
+ {
+ continue; // Category header selected, ignore
+ }
+
+ std::string HelpText = SelectedCmd->Cmd->HelpText();
+ std::vector<std::string> Lines = SplitLines(HelpText);
+ std::string Title = fmt::format("zen {} -- {}", SelectedCmd->CmdName, SelectedCmd->CmdSummary);
+ TuiPager(Title, Lines);
+ }
+}
+
+} // namespace zen
diff --git a/src/zen/cmds/help_cmd.h b/src/zen/cmds/help_cmd.h
new file mode 100644
index 000000000..2bccd9db1
--- /dev/null
+++ b/src/zen/cmds/help_cmd.h
@@ -0,0 +1,33 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#include "../zen.h"
+
+#include <span>
+
+namespace zen {
+
+class HelpCommand : public ZenCmdBase
+{
+public:
+ static constexpr char Name[] = "help";
+ static constexpr char Description[] = "Browse command help interactively";
+
+ HelpCommand();
+ ~HelpCommand();
+
+ void SetCommands(std::span<const CommandInfo> Commands) { m_Commands = Commands; }
+
+ virtual void Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) override;
+ virtual cxxopts::Options& Options() override { return m_Options; }
+ virtual ZenCmdCategory& CommandCategory() const override { return g_UtilitiesCategory; }
+
+private:
+ cxxopts::Options m_Options{Name, Description};
+ std::span<const CommandInfo> m_Commands;
+ std::string m_CommandName;
+ bool m_List = false;
+};
+
+} // namespace zen
diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp
index 3277eb856..67c2e6c77 100644
--- a/src/zen/zen.cpp
+++ b/src/zen/zen.cpp
@@ -12,6 +12,7 @@
#include "cmds/copy_cmd.h"
#include "cmds/dedup_cmd.h"
#include "cmds/exec_cmd.h"
+#include "cmds/help_cmd.h"
#include "cmds/hub_cmd.h"
#include "cmds/info_cmd.h"
#include "cmds/print_cmd.h"
@@ -595,6 +596,7 @@ main(int argc, char** argv)
GcCommand GcCmd;
GcStatusCommand GcStatusCmd;
GcStopCommand GcStopCmd;
+ HelpCommand HelpCmd;
HubCommand HubCmd;
ImportOplogCommand ImportOplogCmd;
InfoCommand InfoCmd;
@@ -628,12 +630,7 @@ main(int argc, char** argv)
WorkspaceShareCommand WorkspaceShareCmd;
ServiceCommand ServiceCmd;
- const struct CommandInfo
- {
- const char* CmdName;
- ZenCmdBase* Cmd;
- const char* CmdSummary;
- } Commands[] = {
+ const CommandInfo Commands[] = {
// clang-format off
{AttachCommand::Name, &AttachCmd, AttachCommand::Description},
{BenchCommand::Name, &BenchCmd, BenchCommand::Description},
@@ -654,6 +651,7 @@ main(int argc, char** argv)
{GcStatusCommand::Name, &GcStatusCmd, GcStatusCommand::Description},
{GcStopCommand::Name, &GcStopCmd, GcStopCommand::Description},
{GcCommand::Name, &GcCmd, GcCommand::Description},
+ {HelpCommand::Name, &HelpCmd, HelpCommand::Description},
{HubCommand::Name, &HubCmd, HubCommand::Description},
{InfoCommand::Name, &InfoCmd, InfoCommand::Description},
{JobCommand::Name, &JobCmd, JobCommand::Description},
@@ -694,6 +692,8 @@ main(int argc, char** argv)
// clang-format on
};
+ HelpCmd.SetCommands(Commands);
+
// Build set containing available commands
std::unordered_set<std::string> CommandSet;
diff --git a/src/zen/zen.h b/src/zen/zen.h
index 64d9390a3..b24b61369 100644
--- a/src/zen/zen.h
+++ b/src/zen/zen.h
@@ -80,6 +80,13 @@ public:
static void LogExecutableVersionAndPid();
};
+struct CommandInfo
+{
+ const char* CmdName;
+ ZenCmdBase* Cmd;
+ const char* CmdSummary;
+};
+
class StorageCommand : public ZenCmdBase
{
virtual ZenCmdCategory& CommandCategory() const override { return g_StorageCategory; }
diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp
index 124132aed..35c38e7b9 100644
--- a/src/zenutil/consoletui.cpp
+++ b/src/zenutil/consoletui.cpp
@@ -8,12 +8,17 @@
# include <zencore/windows.h>
#else
# include <poll.h>
+# include <signal.h>
# include <sys/ioctl.h>
# include <termios.h>
# include <unistd.h>
#endif
+#include <algorithm>
+#include <atomic>
+#include <cerrno>
#include <cstdio>
+#include <cstring>
namespace zen {
@@ -53,45 +58,7 @@ 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;
- }
- }
- }
-}
+static char s_LastChar = 0;
#else // POSIX
@@ -129,6 +96,13 @@ public:
{
m_Valid = true;
}
+
+ // Install SIGWINCH handler for terminal resize detection
+ s_GotSigWinch = 0;
+ struct sigaction Sa = {};
+ Sa.sa_handler = SigWinchHandler;
+ Sa.sa_flags = SA_RESTART;
+ sigaction(SIGWINCH, &Sa, &m_OldSigAction);
}
~RawModeGuard()
@@ -137,13 +111,15 @@ public:
{
tcsetattr(STDIN_FILENO, TCSANOW, &m_OldAttrs);
}
+ sigaction(SIGWINCH, &m_OldSigAction, nullptr);
}
bool IsValid() const { return m_Valid; }
private:
- struct termios m_OldAttrs = {};
- bool m_Valid = false;
+ struct termios m_OldAttrs = {};
+ struct sigaction m_OldSigAction = {};
+ bool m_Valid = false;
};
static int
@@ -168,48 +144,15 @@ ReadByteWithTimeout(int TimeoutMs)
static struct termios s_SavedAttrs = {};
static bool s_InLiveMode = false;
-enum class ConsoleKey
-{
- Unknown,
- ArrowUp,
- ArrowDown,
- Enter,
- Escape,
-};
+static char s_LastChar = 0;
-static ConsoleKey
-ReadKey()
-{
- unsigned char c = 0;
- if (read(STDIN_FILENO, &c, 1) != 1)
- {
- return ConsoleKey::Escape; // treat read error as cancel
- }
+// SIGWINCH (terminal resize) flag — set by signal handler, consumed by TuiReadKey()
+static volatile sig_atomic_t s_GotSigWinch = 0;
- 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;
+static void
+SigWinchHandler(int /*Sig*/)
+{
+ s_GotSigWinch = 1;
}
#endif // ZEN_PLATFORM_WINDOWS / POSIX
@@ -267,112 +210,273 @@ IsTuiAvailable()
return Cached;
}
+// Case-insensitive substring match
+static bool
+ContainsCaseInsensitive(const std::string& Haystack, const std::string& Needle)
+{
+ if (Needle.empty())
+ {
+ return true;
+ }
+ auto It = std::search(Haystack.begin(), Haystack.end(), Needle.begin(), Needle.end(), [](char A, char B) {
+ return std::tolower(static_cast<unsigned char>(A)) == std::tolower(static_cast<unsigned char>(B));
+ });
+ return It != Haystack.end();
+}
+
int
-TuiPickOne(std::string_view Title, std::span<const std::string> Items)
+TuiPickOne(std::string_view Title, std::span<const std::string> Items, int InitialSelection, std::string* InOutFilter)
{
- EnableVirtualTerminal();
+ TuiEnterAlternateScreen();
-#if ZEN_PLATFORM_WINDOWS
- ConsoleCodePageGuard CodePageGuard(CP_UTF8);
-#else
- RawModeGuard RawMode;
- if (!RawMode.IsValid())
+ const int TotalCount = static_cast<int>(Items.size());
+ constexpr int kIndicatorLen = 3; // " ▶ " or " " — 3 display columns
+
+ // Filter state — seed from caller if provided
+ std::string Filter = InOutFilter ? *InOutFilter : std::string{};
+ std::vector<int> Visible; // Indices into Items that match the current filter
+ int CursorPos = 0; // Index into Visible
+ int ScrollTop = 0; // First visible index in the viewport
+
+ auto RebuildVisible = [&] {
+ Visible.clear();
+ for (int i = 0; i < TotalCount; ++i)
+ {
+ if (ContainsCaseInsensitive(Items[i], Filter))
+ {
+ Visible.push_back(i);
+ }
+ }
+ CursorPos = 0;
+ ScrollTop = 0;
+ };
+
+ RebuildVisible();
+
+ // Apply initial selection: find it in the Visible list
+ if (InitialSelection > 0 && InitialSelection < TotalCount)
{
- return -1;
+ for (int i = 0; i < static_cast<int>(Visible.size()); ++i)
+ {
+ if (Visible[i] == InitialSelection)
+ {
+ CursorPos = i;
+ break;
+ }
+ }
}
-#endif
- const int Count = static_cast<int>(Items.size());
- int SelectedIndex = 0;
+ // Layout: Row 1 = title bar, Row 2 = filter (optional), rows 3..N-1 = items, row N = hint footer
+ // kFixedRows = title bar (1) + hint footer (1) = 2
+ constexpr int kFixedRows = 2;
- printf("\n%.*s\n\n", static_cast<int>(Title.size()), Title.data());
+ auto GetPageSize = [&]() -> int {
+ int Rows = static_cast<int>(TuiConsoleRows());
+ int FilterOverhead = Filter.empty() ? 0 : 1;
+ int Available = Rows - kFixedRows - FilterOverhead;
+ return std::max(Available, 1);
+ };
- // Hide cursor during interaction
- printf("\033[?25l");
+ auto EnsureCursorVisible = [&] {
+ int PageSize = GetPageSize();
+ int VisibleCount = static_cast<int>(Visible.size());
+ if (CursorPos < ScrollTop)
+ {
+ ScrollTop = CursorPos;
+ }
+ else if (CursorPos >= ScrollTop + PageSize)
+ {
+ ScrollTop = CursorPos - PageSize + 1;
+ }
+ int MaxScroll = std::max(0, VisibleCount - PageSize);
+ ScrollTop = std::clamp(ScrollTop, 0, MaxScroll);
+ };
- // 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;
+ auto Render = [&] {
+ uint32_t Cols = TuiConsoleColumns();
+ uint32_t Rows = TuiConsoleRows();
+ int MaxTextLen = static_cast<int>(Cols) - kIndicatorLen;
+ int VisibleCount = static_cast<int>(Visible.size());
+ int PageSize = GetPageSize();
- for (int i = 0; i < Count; ++i)
- {
- bool IsSelected = (i == SelectedIndex);
+ TuiCursorHome();
- printf("\r\033[K"); // erase line
+ // Row 1: Title bar (reverse video)
+ TuiMoveCursor(1, 1);
+ TuiEraseLine();
+ printf("\033[1;7m"); // bold + reverse
+ printf(" %.*s", static_cast<int>(std::min(static_cast<uint32_t>(Title.size()), Cols - 1)), Title.data());
+ printf("\033[0m");
- if (IsSelected)
- {
- printf("\033[1;7m"); // bold + reverse video
- }
+ uint32_t CurrentRow = 2;
- // \xe2\x96\xb6 = U+25B6 BLACK RIGHT-POINTING TRIANGLE (▶)
- const char* Indicator = IsSelected ? " \xe2\x96\xb6 " : " ";
+ // Optional filter bar
+ if (!Filter.empty())
+ {
+ TuiMoveCursor(CurrentRow, 1);
+ TuiEraseLine();
+ printf(" \033[33mfilter:\033[0m %s", Filter.c_str());
+ if (VisibleCount == 0)
+ {
+ printf(" \033[2m(no matches)\033[0m");
+ }
+ ++CurrentRow;
+ }
- printf("%s%s", Indicator, Items[i].c_str());
+ // Item viewport
+ int ViewEnd = std::min(ScrollTop + PageSize, VisibleCount);
- if (IsSelected)
- {
- printf("\033[0m"); // reset attributes
- }
+ for (int Row = 0; Row < PageSize; ++Row)
+ {
+ int i = ScrollTop + Row;
- printf("\n");
- }
+ TuiMoveCursor(CurrentRow + static_cast<uint32_t>(Row), 1);
+ TuiEraseLine();
- // Blank separator line
- printf("\r\033[K\n");
+ if (i < VisibleCount)
+ {
+ bool IsSelected = (i == CursorPos);
+
+ if (IsSelected)
+ {
+ printf("\033[1;7m");
+ }
+
+ const char* Indicator = IsSelected ? " \xe2\x96\xb6 " : " ";
+ const std::string& ItemText = Items[Visible[i]];
+ if (MaxTextLen > 0 && static_cast<int>(ItemText.size()) > MaxTextLen)
+ {
+ printf("%s%.*s...", Indicator, MaxTextLen - 3, ItemText.c_str());
+ }
+ else
+ {
+ printf("%s%s", Indicator, ItemText.c_str());
+ }
+
+ if (IsSelected)
+ {
+ printf("\033[0m");
+ }
+ }
+ }
- // 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");
+ // Hint footer (last row)
+ TuiMoveCursor(Rows, 1);
+ TuiEraseLine();
+ printf("\033[7m");
+ printf(
+ " \xe2\x86\x91/\xe2\x86\x93 navigate "
+ "Enter confirm "
+ "Esc %s "
+ "Type to filter",
+ Filter.empty() ? "cancel" : "clear");
+ if (ScrollTop > 0 || ViewEnd < VisibleCount)
+ {
+ printf(" [%d-%d of %d]", ScrollTop + 1, ViewEnd, VisibleCount);
+ }
+ printf("\033[0m");
- fflush(stdout);
+ TuiFlush();
};
- RenderAll();
+ EnsureCursorVisible();
+ Render();
int Result = -1;
bool Done = false;
while (!Done)
{
- ConsoleKey Key = ReadKey();
+ ConsoleKey Key = TuiReadKey();
+ int VisibleCount = static_cast<int>(Visible.size());
+
switch (Key)
{
case ConsoleKey::ArrowUp:
- SelectedIndex = (SelectedIndex - 1 + Count) % Count;
- RenderAll();
+ if (VisibleCount > 0)
+ {
+ CursorPos = (CursorPos - 1 + VisibleCount) % VisibleCount;
+ EnsureCursorVisible();
+ }
break;
case ConsoleKey::ArrowDown:
- SelectedIndex = (SelectedIndex + 1) % Count;
- RenderAll();
+ if (VisibleCount > 0)
+ {
+ CursorPos = (CursorPos + 1) % VisibleCount;
+ EnsureCursorVisible();
+ }
+ break;
+
+ case ConsoleKey::PageUp:
+ if (VisibleCount > 0)
+ {
+ CursorPos = std::max(0, CursorPos - GetPageSize());
+ EnsureCursorVisible();
+ }
+ break;
+
+ case ConsoleKey::PageDown:
+ if (VisibleCount > 0)
+ {
+ CursorPos = std::min(VisibleCount - 1, CursorPos + GetPageSize());
+ EnsureCursorVisible();
+ }
break;
case ConsoleKey::Enter:
- Result = SelectedIndex;
- Done = true;
+ if (VisibleCount > 0)
+ {
+ Result = Visible[CursorPos];
+ }
+ Done = true;
break;
case ConsoleKey::Escape:
- Done = true;
+ if (!Filter.empty())
+ {
+ Filter.clear();
+ RebuildVisible();
+ EnsureCursorVisible();
+ }
+ else
+ {
+ Done = true;
+ }
+ break;
+
+ case ConsoleKey::Backspace:
+ if (!Filter.empty())
+ {
+ Filter.pop_back();
+ RebuildVisible();
+ EnsureCursorVisible();
+ }
+ break;
+
+ case ConsoleKey::Char:
+ Filter += TuiReadKeyChar();
+ RebuildVisible();
+ EnsureCursorVisible();
+ break;
+
+ case ConsoleKey::Resize:
+ EnsureCursorVisible();
break;
default:
break;
}
+
+ Render();
}
- // Restore cursor and add a blank line for visual separation
- printf("\033[?25h\n");
- fflush(stdout);
+ // Persist filter state for the caller
+ if (InOutFilter)
+ {
+ *InOutFilter = std::move(Filter);
+ }
+
+ TuiExitAlternateScreen();
return Result;
}
@@ -389,7 +493,17 @@ TuiEnterAlternateScreen()
printf("\033[?25l"); // Hide cursor
fflush(stdout);
-#if !ZEN_PLATFORM_WINDOWS
+#if ZEN_PLATFORM_WINDOWS
+ // Enable window-size events so ReadConsoleInput returns WINDOW_BUFFER_SIZE_EVENT
+ {
+ HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
+ DWORD dwMode = 0;
+ if (GetConsoleMode(hStdin, &dwMode))
+ {
+ SetConsoleMode(hStdin, dwMode | ENABLE_WINDOW_INPUT);
+ }
+ }
+#else
if (tcgetattr(STDIN_FILENO, &s_SavedAttrs) == 0)
{
struct termios Raw = s_SavedAttrs;
@@ -403,6 +517,13 @@ TuiEnterAlternateScreen()
s_InLiveMode = true;
}
}
+
+ // Install SIGWINCH handler for terminal resize detection
+ s_GotSigWinch = 0;
+ struct sigaction Sa = {};
+ Sa.sa_handler = SigWinchHandler;
+ Sa.sa_flags = SA_RESTART;
+ sigaction(SIGWINCH, &Sa, nullptr);
#endif
}
@@ -419,6 +540,9 @@ TuiExitAlternateScreen()
tcsetattr(STDIN_FILENO, TCSANOW, &s_SavedAttrs);
s_InLiveMode = false;
}
+
+ // Restore default SIGWINCH handler
+ signal(SIGWINCH, SIG_DFL);
#endif
}
@@ -545,4 +669,461 @@ TuiShowCursor(bool Show)
}
}
+//////////////////////////////////////////////////////////////////////////
+// Public key reading
+
+ConsoleKey
+TuiReadKey()
+{
+#if ZEN_PLATFORM_WINDOWS
+ HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
+ INPUT_RECORD Record{};
+ DWORD dwRead = 0;
+ while (true)
+ {
+ if (!ReadConsoleInputA(hStdin, &Record, 1, &dwRead))
+ {
+ return ConsoleKey::Escape;
+ }
+ if (Record.EventType == WINDOW_BUFFER_SIZE_EVENT)
+ {
+ return ConsoleKey::Resize;
+ }
+ if (Record.EventType != KEY_EVENT || !Record.Event.KeyEvent.bKeyDown)
+ {
+ continue;
+ }
+ switch (Record.Event.KeyEvent.wVirtualKeyCode)
+ {
+ case VK_UP:
+ return ConsoleKey::ArrowUp;
+ case VK_DOWN:
+ return ConsoleKey::ArrowDown;
+ case VK_LEFT:
+ return ConsoleKey::ArrowLeft;
+ case VK_RIGHT:
+ return ConsoleKey::ArrowRight;
+ case VK_PRIOR:
+ return ConsoleKey::PageUp;
+ case VK_NEXT:
+ return ConsoleKey::PageDown;
+ case VK_HOME:
+ return ConsoleKey::Home;
+ case VK_END:
+ return ConsoleKey::End;
+ case VK_RETURN:
+ return ConsoleKey::Enter;
+ case VK_ESCAPE:
+ return ConsoleKey::Escape;
+ case VK_BACK:
+ return ConsoleKey::Backspace;
+ default:
+ {
+ char ch = Record.Event.KeyEvent.uChar.AsciiChar;
+ if (ch >= 32 && ch < 127)
+ {
+ s_LastChar = ch;
+ return ConsoleKey::Char;
+ }
+ break;
+ }
+ }
+ }
+#else
+ // Check for pending SIGWINCH before blocking on read
+ if (s_GotSigWinch)
+ {
+ s_GotSigWinch = 0;
+ return ConsoleKey::Resize;
+ }
+
+ unsigned char c = 0;
+ if (read(STDIN_FILENO, &c, 1) != 1)
+ {
+ // read() returns -1 with EINTR when interrupted by SIGWINCH
+ if (errno == EINTR && s_GotSigWinch)
+ {
+ s_GotSigWinch = 0;
+ return ConsoleKey::Resize;
+ }
+ return ConsoleKey::Escape;
+ }
+
+ if (c == 27) // ESC or escape sequence
+ {
+ int Next = ReadByteWithTimeout(50);
+ if (Next == '[')
+ {
+ int Code = ReadByteWithTimeout(50);
+ switch (Code)
+ {
+ case 'A':
+ return ConsoleKey::ArrowUp;
+ case 'B':
+ return ConsoleKey::ArrowDown;
+ case 'C':
+ return ConsoleKey::ArrowRight;
+ case 'D':
+ return ConsoleKey::ArrowLeft;
+ case 'H':
+ return ConsoleKey::Home;
+ case 'F':
+ return ConsoleKey::End;
+ case '5':
+ if (ReadByteWithTimeout(50) == '~')
+ {
+ return ConsoleKey::PageUp;
+ }
+ break;
+ case '6':
+ if (ReadByteWithTimeout(50) == '~')
+ {
+ return ConsoleKey::PageDown;
+ }
+ break;
+ default:
+ break;
+ }
+ }
+ return ConsoleKey::Escape;
+ }
+
+ if (c == '\r' || c == '\n')
+ {
+ return ConsoleKey::Enter;
+ }
+ if (c == 127 || c == 8)
+ {
+ return ConsoleKey::Backspace;
+ }
+ if (c >= 32 && c < 127)
+ {
+ s_LastChar = c;
+ return ConsoleKey::Char;
+ }
+
+ return ConsoleKey::Unknown;
+#endif
+}
+
+char
+TuiReadKeyChar()
+{
+ return s_LastChar;
+}
+
+//////////////////////////////////////////////////////////////////////////
+// TuiPager — fullscreen scrollable text viewer with search
+
+void
+TuiPager(std::string_view Title, const std::vector<std::string>& Lines)
+{
+ TuiEnterAlternateScreen();
+
+ const uint32_t TotalLines = static_cast<uint32_t>(Lines.size());
+ uint32_t TopLine = 0; // Index of the first visible line
+
+ // Search state
+ std::string SearchQuery;
+ bool SearchActive = false; // Currently typing a search query
+ int32_t SearchMatchLine = -1; // Line of current match (-1 = none)
+ bool SearchFailed = false; // True if last search found nothing
+ bool SearchWrapped = false; // True if search wrapped around
+
+ auto GetPageHeight = [&]() -> uint32_t {
+ uint32_t Rows = TuiConsoleRows();
+ return (Rows > 2) ? (Rows - 2) : 1; // Reserve 2 lines: title bar + status bar
+ };
+
+ auto ClampTop = [&]() {
+ uint32_t PageH = GetPageHeight();
+ if (TotalLines <= PageH)
+ {
+ TopLine = 0;
+ }
+ else if (TopLine > TotalLines - PageH)
+ {
+ TopLine = TotalLines - PageH;
+ }
+ };
+
+ auto Render = [&]() {
+ uint32_t Cols = TuiConsoleColumns();
+ uint32_t Rows = TuiConsoleRows();
+ uint32_t PageH = GetPageHeight();
+
+ TuiCursorHome();
+
+ // Title bar (row 1) — reverse video
+ TuiMoveCursor(1, 1);
+ printf("\033[1;7m"); // bold + reverse
+ TuiEraseLine();
+
+ // Build title string: " Title (line X-Y of Z)"
+ uint32_t LastVisible = std::min(TopLine + PageH, TotalLines);
+ printf(" %.*s (lines %u-%u of %u)",
+ static_cast<int>(std::min(static_cast<uint32_t>(Title.size()), Cols - 30)),
+ Title.data(),
+ TotalLines > 0 ? TopLine + 1 : 0,
+ LastVisible,
+ TotalLines);
+
+ printf("\033[0m"); // reset
+
+ // Content lines (rows 2 .. Rows-1)
+ for (uint32_t i = 0; i < PageH; ++i)
+ {
+ TuiMoveCursor(i + 2, 1);
+ TuiEraseLine();
+
+ uint32_t LineIdx = TopLine + i;
+ if (LineIdx < TotalLines)
+ {
+ const std::string& Line = Lines[LineIdx];
+
+ // Highlight search match line
+ if (SearchMatchLine >= 0 && LineIdx == static_cast<uint32_t>(SearchMatchLine))
+ {
+ printf("\033[43;30m"); // yellow background, black text
+ }
+
+ // Truncate to terminal width
+ if (Line.size() <= Cols)
+ {
+ printf("%s", Line.c_str());
+ }
+ else
+ {
+ printf("%.*s", static_cast<int>(Cols), Line.c_str());
+ }
+
+ if (SearchMatchLine >= 0 && LineIdx == static_cast<uint32_t>(SearchMatchLine))
+ {
+ printf("\033[0m");
+ }
+ }
+ }
+
+ // Status bar (last row)
+ TuiMoveCursor(Rows, 1);
+ TuiEraseLine();
+ printf("\033[7m"); // reverse video
+
+ if (SearchActive)
+ {
+ printf(" /%s", SearchQuery.c_str());
+ TuiShowCursor(true);
+ }
+ else if (SearchFailed)
+ {
+ printf(" Pattern not found: %s", SearchQuery.c_str());
+ }
+ else if (SearchWrapped)
+ {
+ printf(" Search wrapped: %s", SearchQuery.c_str());
+ }
+ else
+ {
+ // Scroll percentage
+ if (TotalLines <= PageH)
+ {
+ printf(" (All)");
+ }
+ else if (TopLine == 0)
+ {
+ printf(" (Top)");
+ }
+ else if (TopLine + PageH >= TotalLines)
+ {
+ printf(" (End)");
+ }
+ else
+ {
+ uint32_t Pct = (TopLine * 100) / (TotalLines - PageH);
+ printf(" (%u%%)", Pct);
+ }
+
+ printf(" q:quit /:search n:next");
+ }
+
+ printf("\033[0m");
+ TuiFlush();
+ };
+
+ // Find next occurrence of SearchQuery starting from the given line
+ auto FindNext = [&](uint32_t StartLine, bool Wrap) -> bool {
+ if (SearchQuery.empty())
+ {
+ return false;
+ }
+
+ for (uint32_t i = 0; i < TotalLines; ++i)
+ {
+ uint32_t Idx = (StartLine + i) % TotalLines;
+ if (Idx < StartLine && !Wrap)
+ {
+ break;
+ }
+
+ auto Pos = Lines[Idx].find(SearchQuery);
+ if (Pos != std::string::npos)
+ {
+ SearchMatchLine = static_cast<int32_t>(Idx);
+ SearchFailed = false;
+ SearchWrapped = Wrap && (Idx < StartLine);
+
+ // Scroll so the match is visible
+ uint32_t PageH = GetPageHeight();
+ if (static_cast<uint32_t>(SearchMatchLine) < TopLine || static_cast<uint32_t>(SearchMatchLine) >= TopLine + PageH)
+ {
+ TopLine = static_cast<uint32_t>(SearchMatchLine);
+ if (TopLine > 3)
+ {
+ TopLine -= 3; // Show a few lines of context above
+ }
+ else
+ {
+ TopLine = 0;
+ }
+ ClampTop();
+ }
+ return true;
+ }
+ }
+
+ SearchFailed = true;
+ SearchWrapped = false;
+ SearchMatchLine = -1;
+ return false;
+ };
+
+ ClampTop();
+ Render();
+
+ bool Done = false;
+ while (!Done)
+ {
+ ConsoleKey Key = TuiReadKey();
+
+ if (SearchActive)
+ {
+ // In search input mode
+ switch (Key)
+ {
+ case ConsoleKey::Enter:
+ SearchActive = false;
+ TuiShowCursor(false);
+ FindNext(TopLine, true);
+ break;
+ case ConsoleKey::Escape:
+ SearchActive = false;
+ SearchFailed = false;
+ SearchMatchLine = -1;
+ TuiShowCursor(false);
+ break;
+ case ConsoleKey::Backspace:
+ if (!SearchQuery.empty())
+ {
+ SearchQuery.pop_back();
+ }
+ break;
+ case ConsoleKey::Char:
+ SearchQuery += TuiReadKeyChar();
+ break;
+ default:
+ break;
+ }
+ Render();
+ continue;
+ }
+
+ // Normal navigation mode
+ SearchFailed = false;
+ SearchWrapped = false;
+
+ uint32_t PageH = GetPageHeight();
+
+ switch (Key)
+ {
+ case ConsoleKey::ArrowUp:
+ if (TopLine > 0)
+ {
+ --TopLine;
+ }
+ break;
+
+ case ConsoleKey::ArrowDown:
+ if (TopLine + PageH < TotalLines)
+ {
+ ++TopLine;
+ }
+ break;
+
+ case ConsoleKey::PageUp:
+ if (TopLine >= PageH)
+ {
+ TopLine -= PageH;
+ }
+ else
+ {
+ TopLine = 0;
+ }
+ break;
+
+ case ConsoleKey::PageDown:
+ TopLine += PageH;
+ ClampTop();
+ break;
+
+ case ConsoleKey::Home:
+ TopLine = 0;
+ break;
+
+ case ConsoleKey::End:
+ if (TotalLines > PageH)
+ {
+ TopLine = TotalLines - PageH;
+ }
+ break;
+
+ case ConsoleKey::Char:
+ {
+ char ch = TuiReadKeyChar();
+ if (ch == 'q' || ch == 'Q')
+ {
+ Done = true;
+ }
+ else if (ch == '/')
+ {
+ SearchActive = true;
+ SearchQuery.clear();
+ SearchMatchLine = -1;
+ }
+ else if (ch == 'n' || ch == 'N')
+ {
+ // Find next match
+ uint32_t Start = (SearchMatchLine >= 0) ? static_cast<uint32_t>(SearchMatchLine) + 1 : TopLine;
+ FindNext(Start, true);
+ }
+ break;
+ }
+
+ case ConsoleKey::Escape:
+ Done = true;
+ break;
+
+ case ConsoleKey::Resize:
+ ClampTop();
+ break;
+
+ default:
+ break;
+ }
+
+ Render();
+ }
+
+ TuiExitAlternateScreen();
+}
+
} // namespace zen
diff --git a/src/zenutil/include/zenutil/consoletui.h b/src/zenutil/include/zenutil/consoletui.h
index 22737589b..b1ab0f3fb 100644
--- a/src/zenutil/include/zenutil/consoletui.h
+++ b/src/zenutil/include/zenutil/consoletui.h
@@ -6,9 +6,43 @@
#include <span>
#include <string>
#include <string_view>
+#include <vector>
namespace zen {
+// Key identifiers returned by TuiReadKey().
+enum class ConsoleKey
+{
+ Unknown,
+ ArrowUp,
+ ArrowDown,
+ ArrowLeft,
+ ArrowRight,
+ PageUp,
+ PageDown,
+ Home,
+ End,
+ Enter,
+ Escape,
+ Backspace,
+ Char, // A printable character — retrieve with TuiReadKeyChar()
+ Resize, // Terminal was resized — callers should re-query dimensions and re-render
+};
+
+// Blocking read of a single key press. Returns the key identifier.
+// For ConsoleKey::Char, call TuiReadKeyChar() to get the character.
+// Precondition: terminal should be in raw/alternate-screen mode.
+ConsoleKey TuiReadKey();
+
+// Returns the character from the most recent TuiReadKey() call that returned ConsoleKey::Char.
+char TuiReadKeyChar();
+
+// Display text in a fullscreen pager with scrolling and search.
+// Title is shown in the status bar. Lines are the pre-split text lines.
+// Returns when the user presses 'q' or Escape.
+// Precondition: IsTuiAvailable() must be true.
+void TuiPager(std::string_view Title, const std::vector<std::string>& Lines);
+
// Returns the width of the console in columns, or Default if it cannot be determined.
uint32_t TuiConsoleColumns(uint32_t Default = 120);
@@ -29,12 +63,16 @@ bool IsTuiAvailable();
//
// - Title: a short description printed once above the list
// - Items: pre-formatted display labels, one per selectable entry
+// - InitialSelection: index of the item to highlight initially (default 0)
+// - InOutFilter: if non-null, seeded with this filter text on entry and
+// updated with the current filter text on exit (for round-trip persistence)
//
// Arrow keys (↑/↓) navigate the selection, Enter confirms, Esc cancels.
-// Returns the index of the selected item, or -1 if the user cancelled.
+// Type to incrementally filter the list. Returns the index of the selected
+// item in the original Items array, or -1 if the user cancelled.
//
// Precondition: IsTuiAvailable() must be true.
-int TuiPickOne(std::string_view Title, std::span<const std::string> Items);
+int TuiPickOne(std::string_view Title, std::span<const std::string> Items, int InitialSelection = 0, std::string* InOutFilter = nullptr);
// Enter the alternate screen buffer for fullscreen live-update mode.
// Hides the cursor. On POSIX, switches to raw/unbuffered terminal input.