aboutsummaryrefslogtreecommitdiff
path: root/src/zenutil
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-30 17:38:45 +0200
committerStefan Boberg <[email protected]>2026-03-30 17:38:45 +0200
commit3edce89333d98f95f8f5faa06dc13e88376c8133 (patch)
tree5af717e60e00cafd16dce2aaf1ead9ac6fa4e962 /src/zenutil
parentRequest validation and resilience improvements (#864) (diff)
downloadzen-3edce89333d98f95f8f5faa06dc13e88376c8133.tar.xz
zen-3edce89333d98f95f8f5faa06dc13e88376c8133.zip
Add interactive help command to zen CLI
- Add `zen help` command with three modes: - Interactive picker (TuiPickOne) to browse all commands by category - `zen help <command>` to view a specific command's help in a pager - `zen help --list` for non-interactive plain text listing - Extend consoletui with new TUI primitives: - Public ConsoleKey enum with PageUp/Down, Home/End, Backspace, Char, Resize - TuiReadKey()/TuiReadKeyChar() for cross-platform key input - TuiPager() for fullscreen scrollable text viewing with `/` search - Enhance TuiPickOne with incremental type-to-filter, viewport scrolling, line truncation, initial selection and filter persistence - Switch TuiPickOne to alternate screen buffer for clean resize handling - Handle console resize events (WINDOW_BUFFER_SIZE_EVENT on Windows, SIGWINCH on POSIX) - Move CommandInfo struct to zen.h for reuse by help command
Diffstat (limited to 'src/zenutil')
-rw-r--r--src/zenutil/consoletui.cpp867
-rw-r--r--src/zenutil/include/zenutil/consoletui.h42
2 files changed, 764 insertions, 145 deletions
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.