[Lldb-commits] [lldb] [lldb] Add an interactive picker IOHandler (PR #237514)
Med Ismail Bennani via lldb-commits
lldb-commits at lists.llvm.org
Sat Oct 31 00:41:13 PDT 2026
https://github.com/medismailben created https://github.com/llvm/llvm-project/pull/237514
This patch adds `IOHandlerPicker`, an ANSI-based IOHandler that lets the
user choose one or more items from a table. It supports single-select and
multi-select modes, arrow and vim-style navigation, and paging. It draws on
the alternate screen buffer so the user's scrollback is restored on exit,
redraws itself when the terminal is resized, and reports a canceled result
when the input is not an interactive terminal.
The statusline can't stay up underneath the picker: its scroll region
also applies to the alternate screen, and its cursor save/restore shares
the slot that leaving the alternate screen restores. This patch
addresses that by adding `Debugger::HideStatusline` and
`Debugger::ShowStatusline`, which the picker uses to remove the
statusline while it owns the screen and bring it back once it's done.
`AnsiTerminal.h` didn't have macros for the DEC private modes the picker
toggles: cursor visibility, auto-wrap, and the alternate screen buffer.
This patch adds them there, so we don't hard-code those escape sequences
in the picker.
This patch also adds a `test-picker` command, available only in builds with
assertions enabled, that shows the picker with generated items. The new
pexpect tests use it to exercise navigation, selection, cancellation,
and scrolling.
Assisted-by: Claude
Signed-off-by: Med Ismail Bennani <ismail at bennani.ma>
---
<sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
>From 8ebcb98f2f3903fe495847a9926b9c4c0dbe9e1a Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <ismail at bennani.ma>
Date: Mon, 5 Oct 2026 17:46:22 -0700
Subject: [PATCH] [lldb] Add an interactive picker IOHandler
This patch adds `IOHandlerPicker`, an ANSI-based IOHandler that lets the
user choose one or more items from a table. It supports single-select and
multi-select modes, arrow and vim-style navigation, and paging. It draws on
the alternate screen buffer so the user's scrollback is restored on exit,
redraws itself when the terminal is resized, and reports a canceled result
when the input is not an interactive terminal.
The statusline can't stay up underneath the picker: its scroll region
also applies to the alternate screen, and its cursor save/restore shares
the slot that leaving the alternate screen restores. This patch
addresses that by adding `Debugger::HideStatusline` and
`Debugger::ShowStatusline`, which the picker uses to remove the
statusline while it owns the screen and bring it back once it's done.
`AnsiTerminal.h` didn't have macros for the DEC private modes the picker
toggles: cursor visibility, auto-wrap, and the alternate screen buffer.
This patch adds them there, so we don't hard-code those escape sequences
in the picker.
This patch also adds a `test-picker` command, available only in builds with
assertions enabled, that shows the picker with generated items. The new
pexpect tests use it to exercise navigation, selection, cancellation,
and scrolling.
Assisted-by: Claude
Signed-off-by: Med Ismail Bennani <ismail at bennani.ma>
---
lldb/include/lldb/Core/Debugger.h | 6 +
lldb/include/lldb/Core/IOHandler.h | 1 +
lldb/include/lldb/Core/IOHandlerPicker.h | 125 +++++
lldb/include/lldb/Utility/AnsiTerminal.h | 12 +
lldb/source/Commands/CMakeLists.txt | 1 +
.../Commands/CommandObjectTestPicker.cpp | 101 ++++
.../source/Commands/CommandObjectTestPicker.h | 25 +
lldb/source/Core/CMakeLists.txt | 1 +
lldb/source/Core/Debugger.cpp | 26 +-
lldb/source/Core/IOHandlerPicker.cpp | 449 ++++++++++++++++++
.../source/Interpreter/CommandInterpreter.cpp | 6 +
.../iohandler/picker/TestIOHandlerPicker.py | 90 ++++
12 files changed, 835 insertions(+), 8 deletions(-)
create mode 100644 lldb/include/lldb/Core/IOHandlerPicker.h
create mode 100644 lldb/source/Commands/CommandObjectTestPicker.cpp
create mode 100644 lldb/source/Commands/CommandObjectTestPicker.h
create mode 100644 lldb/source/Core/IOHandlerPicker.cpp
create mode 100644 lldb/test/API/iohandler/picker/TestIOHandlerPicker.py
diff --git a/lldb/include/lldb/Core/Debugger.h b/lldb/include/lldb/Core/Debugger.h
index 918a0fb71a1bd3..15d5a7605bae73 100644
--- a/lldb/include/lldb/Core/Debugger.h
+++ b/lldb/include/lldb/Core/Debugger.h
@@ -178,6 +178,12 @@ class Debugger : public std::enable_shared_from_this<Debugger>,
void RestoreInputTerminalState();
+ /// Remove the statusline and give its row back to the scroll region.
+ void HideStatusline();
+
+ /// Bring back the statusline after HideStatusline(), if it is enabled.
+ void ShowStatusline();
+
lldb::StreamUP GetAsyncOutputStream();
lldb::StreamUP GetAsyncErrorStream();
diff --git a/lldb/include/lldb/Core/IOHandler.h b/lldb/include/lldb/Core/IOHandler.h
index 2672bbe5da2b35..94e8e9816ffcfa 100644
--- a/lldb/include/lldb/Core/IOHandler.h
+++ b/lldb/include/lldb/Core/IOHandler.h
@@ -47,6 +47,7 @@ class IOHandler {
PythonInterpreter,
LuaInterpreter,
PythonCode,
+ Picker,
Other
};
diff --git a/lldb/include/lldb/Core/IOHandlerPicker.h b/lldb/include/lldb/Core/IOHandlerPicker.h
new file mode 100644
index 00000000000000..12590202e7feae
--- /dev/null
+++ b/lldb/include/lldb/Core/IOHandlerPicker.h
@@ -0,0 +1,125 @@
+//===----------------------------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_CORE_IOHANDLERPICKER_H
+#define LLDB_CORE_IOHANDLERPICKER_H
+
+#include "lldb/Core/IOHandler.h"
+#include "lldb/Host/Pipe.h"
+#include "lldb/Host/Terminal.h"
+#include "llvm/ADT/BitVector.h"
+#include "llvm/ADT/SmallVector.h"
+
+#include <optional>
+#include <string>
+
+namespace lldb_private {
+
+using PickerColumn = std::string;
+
+/// A row of the picker's table, with one value per column.
+struct PickerItem {
+ llvm::SmallVector<std::string> values;
+ bool initially_selected = false;
+};
+
+enum class PickerMode { SingleSelect, MultiSelect };
+
+struct PickerResult {
+ bool was_canceled = false;
+ /// Indices into the items given to the picker, in increasing order.
+ llvm::SmallVector<size_t> selected_indices;
+};
+
+/// An interactive ANSI-based picker IOHandler.
+///
+/// Supports single-select and multi-select modes, vim-style navigation, and
+/// scrolling. Works in any terminal that supports basic ANSI escape codes.
+/// Reports a canceled result if the input is not an interactive terminal.
+class IOHandlerPicker : public IOHandler {
+public:
+ IOHandlerPicker(Debugger &debugger, llvm::StringRef header,
+ llvm::SmallVector<PickerColumn> columns,
+ llvm::SmallVector<PickerItem, 0> items, PickerMode mode);
+
+ ~IOHandlerPicker() override;
+
+ void Run() override;
+ void Cancel() override;
+ bool Interrupt() override;
+ void GotEOF() override;
+ void TerminalSizeChanged() override;
+
+ const PickerResult &GetResult() const { return m_result; }
+
+private:
+ enum class Key {
+ Up,
+ Down,
+ PageUp,
+ PageDown,
+ Home,
+ End,
+ Space,
+ Enter,
+ /// A byte arrived on m_wakeup_pipe.
+ Wakeup,
+ Escape,
+ Unknown,
+ };
+
+ enum class Input { Ready, Wakeup, Error };
+
+ /// Raw mode hands every key press to ReadKey() as soon as it's typed.
+ bool EnterRawMode();
+ void RestoreTerminalMode();
+
+ Key ReadKey();
+ Input WaitForInput();
+ std::optional<char> ReadByte();
+ Key ReadEscapeSequence();
+ static Key DecodeByte(char c);
+ static Key DecodeEscapeSequence(llvm::StringRef params, char final_byte);
+ void WakeUp();
+
+ void Render();
+ void ScrollToCursor();
+ void ComputeColumnWidths();
+ void UpdateLayout();
+ size_t GetPrefixWidth() const;
+ size_t GetViewportHeight() const;
+
+ std::string m_header;
+ llvm::SmallVector<PickerColumn> m_columns;
+ llvm::SmallVector<PickerItem, 0> m_items;
+ PickerMode m_mode;
+ PickerResult m_result;
+
+ size_t m_cursor_index = 0;
+ /// Index of the first row on screen.
+ size_t m_viewport_start = 0;
+ /// Bit i is set if m_items[i] is checked.
+ llvm::BitVector m_checked;
+
+ llvm::SmallVector<size_t> m_column_widths;
+
+ /// Cached with m_column_widths so a frame never mixes two terminal sizes.
+ size_t m_terminal_width;
+ size_t m_terminal_height;
+
+ TerminalState m_saved_terminal_state;
+
+ /// Lets TerminalSizeChanged() and Cancel(), which can run on other threads,
+ /// wake ReadKey() up from select(). Open for the picker's whole lifetime,
+ /// since TerminalSizeChanged() can come before or after Run().
+ Pipe m_wakeup_pipe;
+};
+
+} // namespace lldb_private
+
+#endif // LLDB_CORE_IOHANDLERPICKER_H
diff --git a/lldb/include/lldb/Utility/AnsiTerminal.h b/lldb/include/lldb/Utility/AnsiTerminal.h
index ee581abc74e09e..213c309dd2f11d 100644
--- a/lldb/include/lldb/Utility/AnsiTerminal.h
+++ b/lldb/include/lldb/Utility/AnsiTerminal.h
@@ -84,6 +84,18 @@
#define ANSI_CSI_ERASE_VIEWPORT ANSI_CSI_ED(2)
// Erase scrollback.
#define ANSI_CSI_ERASE_SCROLLBACK ANSI_CSI_ED(3)
+// DEC Private Mode Set and Reset.
+#define ANSI_CSI_DECSET(mode) ANSI_ESC_START "?" #mode "h"
+#define ANSI_CSI_DECRST(mode) ANSI_ESC_START "?" #mode "l"
+// Show or hide the cursor.
+#define ANSI_CSI_SHOW_CURSOR ANSI_CSI_DECSET(25)
+#define ANSI_CSI_HIDE_CURSOR ANSI_CSI_DECRST(25)
+// Enable or disable auto-wrap.
+#define ANSI_CSI_ENABLE_AUTO_WRAP ANSI_CSI_DECSET(7)
+#define ANSI_CSI_DISABLE_AUTO_WRAP ANSI_CSI_DECRST(7)
+// Switch to or from the alternate screen buffer.
+#define ANSI_CSI_ENTER_ALT_SCREEN ANSI_CSI_DECSET(1049)
+#define ANSI_CSI_LEAVE_ALT_SCREEN ANSI_CSI_DECRST(1049)
// OSC (Operating System Commands)
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
diff --git a/lldb/source/Commands/CMakeLists.txt b/lldb/source/Commands/CMakeLists.txt
index 69e4c45f0b8e5a..ef2d8e5926e05e 100644
--- a/lldb/source/Commands/CMakeLists.txt
+++ b/lldb/source/Commands/CMakeLists.txt
@@ -33,6 +33,7 @@ add_lldb_library(lldbCommands NO_PLUGIN_DEPENDENCIES
CommandObjectSource.cpp
CommandObjectStats.cpp
CommandObjectTarget.cpp
+ CommandObjectTestPicker.cpp
CommandObjectThread.cpp
CommandObjectThreadUtil.cpp
CommandObjectTrace.cpp
diff --git a/lldb/source/Commands/CommandObjectTestPicker.cpp b/lldb/source/Commands/CommandObjectTestPicker.cpp
new file mode 100644
index 00000000000000..f343d577e494ee
--- /dev/null
+++ b/lldb/source/Commands/CommandObjectTestPicker.cpp
@@ -0,0 +1,101 @@
+//===----------------------------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#include "CommandObjectTestPicker.h"
+
+#include "lldb/Core/Debugger.h"
+#include "lldb/Core/IOHandlerPicker.h"
+#include "lldb/Interpreter/CommandInterpreter.h"
+#include "lldb/Interpreter/CommandReturnObject.h"
+
+using namespace lldb;
+using namespace lldb_private;
+
+static const char *const g_names[] = {
+ "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
+ "hotel", "india", "juliet", "kilo", "lima", "mike", "november",
+ "oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform",
+ "victor", "whiskey", "xray", "yankee", "zulu",
+};
+
+/// Generates items in descending id order so that sorting has a visible
+/// effect. Avoids <random> because its output differs across standard
+/// libraries, which would make the tests non-portable.
+static llvm::SmallVector<PickerItem, 0> GenerateItems(size_t count,
+ PickerMode mode) {
+ llvm::SmallVector<PickerItem, 0> items;
+ for (size_t id = count; id > 0; --id) {
+ PickerItem item;
+ item.values = {std::to_string(id), g_names[(id - 1) % std::size(g_names)],
+ std::to_string(id * 7919 % 10000)};
+ item.initially_selected = mode == PickerMode::MultiSelect && id % 3 == 1;
+ items.push_back(std::move(item));
+ }
+ return items;
+}
+
+class CommandObjectTestPickerMode : public CommandObjectParsed {
+public:
+ CommandObjectTestPickerMode(CommandInterpreter &interpreter, PickerMode mode)
+ : CommandObjectParsed(
+ interpreter,
+ mode == PickerMode::SingleSelect ? "test-picker single"
+ : "test-picker multi",
+ mode == PickerMode::SingleSelect
+ ? "Test IOHandlerPicker in single-select mode."
+ : "Test IOHandlerPicker in multi-select mode.",
+ mode == PickerMode::SingleSelect ? "test-picker single [<count>]"
+ : "test-picker multi [<count>]"),
+ m_mode(mode) {
+ AddSimpleArgumentList(eArgTypeCount, eArgRepeatOptional);
+ }
+
+protected:
+ void DoExecute(Args &args, CommandReturnObject &result) override {
+ size_t count = 10;
+ if (args.GetArgumentCount() >= 1 && args[0].ref().getAsInteger(10, count)) {
+ result.AppendErrorWithFormatv("invalid count: '{0}'", args[0].ref());
+ return;
+ }
+
+ llvm::SmallVector<PickerColumn> columns = {"ID", "NAME", "VALUE"};
+ // Keep a copy to print the selected ids.
+ llvm::SmallVector<PickerItem, 0> items = GenerateItems(count, m_mode);
+ auto picker_sp = std::make_shared<IOHandlerPicker>(
+ GetDebugger(),
+ m_mode == PickerMode::SingleSelect ? "Select an item:"
+ : "Toggle items:",
+ std::move(columns), items, m_mode);
+ GetDebugger().RunIOHandlerSync(picker_sp);
+
+ const PickerResult &picked = picker_sp->GetResult();
+ if (picked.was_canceled)
+ result.AppendMessage("canceled");
+ else if (picked.selected_indices.empty())
+ result.AppendMessage("none selected");
+ for (size_t index : picked.selected_indices)
+ result.AppendMessageWithFormatv("selected: {0}", items[index].values[0]);
+ result.SetStatus(eReturnStatusSuccessFinishResult);
+ }
+
+private:
+ PickerMode m_mode;
+};
+
+CommandObjectTestPicker::CommandObjectTestPicker(
+ CommandInterpreter &interpreter)
+ : CommandObjectMultiword(interpreter, "test-picker",
+ "Test the IOHandlerPicker interactive widget.",
+ "test-picker <subcommand> [<count>]") {
+ LoadSubCommand("single", std::make_shared<CommandObjectTestPickerMode>(
+ interpreter, PickerMode::SingleSelect));
+ LoadSubCommand("multi", std::make_shared<CommandObjectTestPickerMode>(
+ interpreter, PickerMode::MultiSelect));
+}
+
+CommandObjectTestPicker::~CommandObjectTestPicker() = default;
diff --git a/lldb/source/Commands/CommandObjectTestPicker.h b/lldb/source/Commands/CommandObjectTestPicker.h
new file mode 100644
index 00000000000000..8a9f0597bd061a
--- /dev/null
+++ b/lldb/source/Commands/CommandObjectTestPicker.h
@@ -0,0 +1,25 @@
+//===----------------------------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_SOURCE_COMMANDS_COMMANDOBJECTTESTPICKER_H
+#define LLDB_SOURCE_COMMANDS_COMMANDOBJECTTESTPICKER_H
+
+#include "lldb/Interpreter/CommandObjectMultiword.h"
+
+namespace lldb_private {
+
+class CommandObjectTestPicker : public CommandObjectMultiword {
+public:
+ CommandObjectTestPicker(CommandInterpreter &interpreter);
+
+ ~CommandObjectTestPicker() override;
+};
+
+} // namespace lldb_private
+
+#endif // LLDB_SOURCE_COMMANDS_COMMANDOBJECTTESTPICKER_H
diff --git a/lldb/source/Core/CMakeLists.txt b/lldb/source/Core/CMakeLists.txt
index f06455983340c2..6d8928c3183a6a 100644
--- a/lldb/source/Core/CMakeLists.txt
+++ b/lldb/source/Core/CMakeLists.txt
@@ -45,6 +45,7 @@ add_lldb_library(lldbCore NO_PLUGIN_DEPENDENCIES
Highlighter.cpp
IOHandler.cpp
IOHandlerCursesGUI.cpp
+ IOHandlerPicker.cpp
Mangled.cpp
Module.cpp
ModuleChild.cpp
diff --git a/lldb/source/Core/Debugger.cpp b/lldb/source/Core/Debugger.cpp
index cbd4aec0ef9179..d5ec66210e91c9 100644
--- a/lldb/source/Core/Debugger.cpp
+++ b/lldb/source/Core/Debugger.cpp
@@ -346,14 +346,8 @@ Status Debugger::SetPropertyValue(const ExecutionContext *exe_ctx,
g_debugger_properties[ePropertyShowStatusline].name) {
// Statusline setting changed. If we have a statusline instance, update it
// now. Otherwise it will get created in the default event handler.
- std::lock_guard<std::mutex> guard(m_statusline_mutex);
- if (StatuslineSupported()) {
- m_statusline.emplace(*this);
- m_statusline->Enable(
- GetSelectedExecutionContextRef(/*adopt_dummy_target=*/true));
- } else {
- m_statusline.reset();
- }
+ HideStatusline();
+ ShowStatusline();
} else if (property_path ==
g_debugger_properties[ePropertyStatuslineFormat].name ||
property_path ==
@@ -1316,6 +1310,22 @@ void Debugger::RestoreInputTerminalState() {
}
}
+void Debugger::HideStatusline() {
+ // Destroy the statusline rather than just disabling it, so that resizes and
+ // redraws from other threads can't draw it back while it's hidden.
+ std::lock_guard<std::mutex> guard(m_statusline_mutex);
+ m_statusline.reset();
+}
+
+void Debugger::ShowStatusline() {
+ std::lock_guard<std::mutex> guard(m_statusline_mutex);
+ if (!m_statusline && StatuslineSupported()) {
+ m_statusline.emplace(*this);
+ m_statusline->Enable(
+ GetSelectedExecutionContextRef(/*adopt_dummy_target=*/true));
+ }
+}
+
void Debugger::RedrawStatusline(
std::optional<ExecutionContextRef> exe_ctx_ref) {
std::lock_guard<std::mutex> guard(m_statusline_mutex);
diff --git a/lldb/source/Core/IOHandlerPicker.cpp b/lldb/source/Core/IOHandlerPicker.cpp
new file mode 100644
index 00000000000000..69d3d9e6973bac
--- /dev/null
+++ b/lldb/source/Core/IOHandlerPicker.cpp
@@ -0,0 +1,449 @@
+//===----------------------------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#include "lldb/Core/IOHandlerPicker.h"
+#include "lldb/Core/Debugger.h"
+#include "lldb/Host/Config.h"
+#include "lldb/Host/StreamFile.h"
+#include "lldb/Utility/AnsiTerminal.h"
+#include "lldb/Utility/LLDBLog.h"
+#include "lldb/Utility/Log.h"
+#include "lldb/Utility/SelectHelper.h"
+#include "llvm/ADT/STLExtras.h"
+#include "llvm/ADT/StringSwitch.h"
+#include "llvm/Support/FormatVariadic.h"
+#include "llvm/Support/Locale.h"
+
+#if LLDB_ENABLE_TERMIOS
+#include <unistd.h>
+#endif
+
+#include <algorithm>
+#include <cassert>
+#include <chrono>
+
+using namespace lldb;
+using namespace lldb_private;
+
+static constexpr size_t g_column_gap = 2;
+
+static constexpr llvm::StringLiteral g_checked_prefix = " [x] ";
+static constexpr llvm::StringLiteral g_unchecked_prefix = " [ ] ";
+static constexpr llvm::StringLiteral g_cursor_prefix = " > ";
+static constexpr llvm::StringLiteral g_no_cursor_prefix = " ";
+
+/// Header, key help, column names, footer, and an empty last line so that
+/// the footer's newline doesn't scroll the screen.
+static constexpr size_t g_reserved_lines = 5;
+
+static constexpr char g_ctrl_c = 0x03;
+static constexpr char g_escape = 0x1b;
+
+static constexpr std::chrono::milliseconds g_escape_sequence_timeout(100);
+
+/// Parameter (0x30-0x3f) or intermediate (0x20-0x2f) byte, see ECMA-48 5.4.
+static bool IsCSIParameterOrIntermediateByte(char c) {
+ return c >= 0x20 && c <= 0x3f;
+}
+
+/// Falls back to the size in bytes when columnWidth() can't measure \a str.
+static size_t GetColumnWidth(llvm::StringRef str) {
+ int width = llvm::sys::locale::columnWidth(str);
+ return width < 0 ? str.size() : width;
+}
+
+IOHandlerPicker::IOHandlerPicker(Debugger &debugger, llvm::StringRef header,
+ llvm::SmallVector<PickerColumn> columns,
+ llvm::SmallVector<PickerItem, 0> items,
+ PickerMode mode)
+ : IOHandler(debugger, IOHandler::Type::Picker), m_header(header.str()),
+ m_columns(std::move(columns)), m_items(std::move(items)), m_mode(mode),
+ m_checked(m_items.size()), m_terminal_width(debugger.GetTerminalWidth()),
+ m_terminal_height(debugger.GetTerminalHeight()) {
+ assert(llvm::all_of(m_items,
+ [&](const PickerItem &item) {
+ return item.values.size() == m_columns.size();
+ }) &&
+ "every picker item must have one value per column");
+
+ for (auto [i, item] : llvm::enumerate(m_items))
+ if (item.initially_selected)
+ m_checked.set(i);
+
+ // Without the pipe, resizes and cross-thread cancels wait for a key press.
+ if (Status error = m_wakeup_pipe.CreateNew(); error.Fail())
+ LLDB_LOG(GetLog(LLDBLog::Host), "IOHandlerPicker: {0}", error);
+}
+
+IOHandlerPicker::~IOHandlerPicker() = default;
+
+size_t IOHandlerPicker::GetPrefixWidth() const {
+ return m_mode == PickerMode::MultiSelect ? g_checked_prefix.size()
+ : g_cursor_prefix.size();
+}
+
+void IOHandlerPicker::ComputeColumnWidths() {
+ m_column_widths.clear();
+ for (size_t i = 0; i < m_columns.size(); ++i) {
+ size_t width = GetColumnWidth(m_columns[i]);
+ for (const PickerItem &item : m_items)
+ width = std::max(width, GetColumnWidth(item.values[i]));
+ m_column_widths.push_back(width);
+ }
+
+ size_t row_width = GetPrefixWidth();
+ for (size_t width : m_column_widths)
+ row_width += width + g_column_gap;
+ if (m_column_widths.empty() || row_width >= m_terminal_width)
+ return;
+
+ // Spread the unused width evenly, the first columns taking the remainder.
+ size_t num_cols = m_column_widths.size();
+ size_t extra = m_terminal_width - row_width;
+ for (size_t i = 0; i < num_cols; ++i) {
+ m_column_widths[i] += extra / num_cols;
+ if (i < extra % num_cols)
+ m_column_widths[i] += 1;
+ }
+}
+
+void IOHandlerPicker::UpdateLayout() {
+ m_terminal_width = m_debugger.GetTerminalWidth();
+ m_terminal_height = m_debugger.GetTerminalHeight();
+ ComputeColumnWidths();
+}
+
+size_t IOHandlerPicker::GetViewportHeight() const {
+ // Even on a tiny terminal, keep the cursor's row on screen.
+ if (m_terminal_height <= g_reserved_lines)
+ return 1;
+ return m_terminal_height - g_reserved_lines;
+}
+
+bool IOHandlerPicker::EnterRawMode() {
+#if LLDB_ENABLE_TERMIOS
+ int fd = GetInputFD();
+ if (fd < 0)
+ return false;
+
+ Terminal terminal(fd);
+ m_saved_terminal_state.Save(terminal, /*save_process_group=*/false);
+ if (auto err = terminal.SetRaw()) {
+ llvm::consumeError(std::move(err));
+ m_saved_terminal_state.Clear();
+ return false;
+ }
+ return true;
+#else
+ return false;
+#endif
+}
+
+void IOHandlerPicker::RestoreTerminalMode() {
+ if (m_saved_terminal_state.IsValid()) {
+ m_saved_terminal_state.Restore();
+ m_saved_terminal_state.Clear();
+ }
+}
+
+IOHandlerPicker::Key IOHandlerPicker::ReadKey() {
+ switch (WaitForInput()) {
+ case Input::Ready:
+ break;
+ case Input::Wakeup:
+ return Key::Wakeup;
+ case Input::Error:
+ return Key::Escape;
+ }
+
+ std::optional<char> c = ReadByte();
+ if (!c)
+ return Key::Escape;
+ if (*c == g_escape)
+ return ReadEscapeSequence();
+ return DecodeByte(*c);
+}
+
+IOHandlerPicker::Input IOHandlerPicker::WaitForInput() {
+ bool has_wakeup_pipe = m_wakeup_pipe.CanRead();
+ int wakeup_fd = m_wakeup_pipe.GetReadFileDescriptor();
+ SelectHelper wait_for_input;
+ wait_for_input.FDSetRead(GetInputFD());
+ if (has_wakeup_pipe)
+ wait_for_input.FDSetRead(wakeup_fd);
+ if (wait_for_input.Select().Fail())
+ return Input::Error;
+ if (!has_wakeup_pipe || !wait_for_input.FDIsSetRead(wakeup_fd))
+ return Input::Ready;
+
+ // Drain the pipe so the next select() blocks.
+ char buf[32];
+ if (llvm::Expected<size_t> bytes = m_wakeup_pipe.Read(buf, sizeof(buf));
+ !bytes)
+ llvm::consumeError(bytes.takeError());
+ return Input::Wakeup;
+}
+
+std::optional<char> IOHandlerPicker::ReadByte() {
+#if LLDB_ENABLE_TERMIOS
+ char c;
+ if (::read(GetInputFD(), &c, 1) == 1)
+ return c;
+#endif
+ return std::nullopt;
+}
+
+IOHandlerPicker::Key IOHandlerPicker::ReadEscapeSequence() {
+ // A lone ESC is the ESC key. A sequence's bytes follow right away.
+ SelectHelper wait_for_sequence;
+ wait_for_sequence.SetTimeout(g_escape_sequence_timeout);
+ wait_for_sequence.FDSetRead(GetInputFD());
+ if (wait_for_sequence.Select().Fail())
+ return Key::Escape;
+
+ // CSI ("ESC [") or SS3 ("ESC O").
+ std::optional<char> intro = ReadByte();
+ if (intro != '[' && intro != 'O')
+ return Key::Unknown;
+
+ // Consume the whole sequence, even for keys we ignore.
+ std::string params;
+ std::optional<char> final_byte = ReadByte();
+ while (intro == '[' && final_byte &&
+ IsCSIParameterOrIntermediateByte(*final_byte)) {
+ params += *final_byte;
+ final_byte = ReadByte();
+ }
+ if (!final_byte)
+ return Key::Escape;
+ return DecodeEscapeSequence(params, *final_byte);
+}
+
+IOHandlerPicker::Key IOHandlerPicker::DecodeByte(char c) {
+ switch (c) {
+ case '\r':
+ case '\n':
+ return Key::Enter;
+ case ' ':
+ return Key::Space;
+ case 'k':
+ return Key::Up;
+ case 'j':
+ return Key::Down;
+ case g_ctrl_c: // Raw mode disables SIGINT, so Ctrl+C arrives as a byte.
+ return Key::Escape;
+ default:
+ return Key::Unknown;
+ }
+}
+
+IOHandlerPicker::Key
+IOHandlerPicker::DecodeEscapeSequence(llvm::StringRef params, char final_byte) {
+ switch (final_byte) {
+ case 'A':
+ return Key::Up;
+ case 'B':
+ return Key::Down;
+ case 'H':
+ return Key::Home;
+ case 'F':
+ return Key::End;
+ case '~':
+ // "ESC [ <code>[;<modifiers>] ~". Home/End: 1/4 on vt220, 7/8 on rxvt.
+ return llvm::StringSwitch<Key>(params.split(';').first)
+ .Cases({"1", "7"}, Key::Home)
+ .Cases({"4", "8"}, Key::End)
+ .Case("5", Key::PageUp)
+ .Case("6", Key::PageDown)
+ .Default(Key::Unknown);
+ default:
+ return Key::Unknown;
+ }
+}
+
+void IOHandlerPicker::ScrollToCursor() {
+ size_t viewport = GetViewportHeight();
+
+ if (m_cursor_index < m_viewport_start)
+ m_viewport_start = m_cursor_index;
+ else if (m_cursor_index >= m_viewport_start + viewport)
+ m_viewport_start = m_cursor_index - viewport + 1;
+}
+
+void IOHandlerPicker::Render() {
+ // Build the whole frame first so it reaches the terminal in one write.
+ std::string frame = ANSI_CSI_RESET_CURSOR;
+
+ // Fit every line to the terminal width so it overwrites the previous frame.
+ auto write_line = [&](llvm::StringRef style, llvm::StringRef content) {
+ frame += style;
+ frame += ansi::TrimAndPad(content, m_terminal_width);
+ frame += ANSI_ESCAPE1(ANSI_CTRL_NORMAL) "\r\n";
+ };
+
+ write_line(ANSI_ESCAPE1(ANSI_CTRL_BOLD), m_header);
+
+ llvm::StringRef help =
+ m_mode == PickerMode::SingleSelect
+ ? " jk: navigate Enter: select ESC: cancel"
+ : " jk: navigate Space: toggle Enter: confirm ESC: cancel";
+ write_line(ANSI_ESCAPE1(ANSI_CTRL_FAINT), help);
+
+ std::string column_names(GetPrefixWidth(), ' ');
+ for (size_t i = 0; i < m_columns.size(); ++i) {
+ const std::string &name = m_columns[i];
+ column_names += name;
+ column_names.append(
+ m_column_widths[i] - GetColumnWidth(name) + g_column_gap, ' ');
+ }
+ write_line(ANSI_ESCAPE1(ANSI_CTRL_FAINT), column_names);
+
+ size_t end = std::min(m_viewport_start + GetViewportHeight(), m_items.size());
+ for (size_t item_idx = m_viewport_start; item_idx < end; ++item_idx) {
+ bool is_cursor = item_idx == m_cursor_index;
+
+ std::string row;
+ if (m_mode == PickerMode::MultiSelect)
+ row = (m_checked[item_idx] ? g_checked_prefix : g_unchecked_prefix).str();
+ else
+ row = (is_cursor ? g_cursor_prefix : g_no_cursor_prefix).str();
+
+ for (auto [value, width] :
+ llvm::zip_equal(m_items[item_idx].values, m_column_widths)) {
+ row += value;
+ row.append(width - GetColumnWidth(value) + g_column_gap, ' ');
+ }
+ write_line(is_cursor ? ANSI_ESCAPE1(ANSI_CTRL_IMAGE_NEGATIVE) : "", row);
+ }
+
+ std::string footer =
+ m_mode == PickerMode::MultiSelect
+ ? llvm::formatv(" {0}/{1} selected", m_checked.count(),
+ m_items.size())
+ .str()
+ : llvm::formatv(" {0}/{1}", m_cursor_index + 1, m_items.size())
+ .str();
+ if (end < m_items.size())
+ footer += " [more below]";
+ if (m_viewport_start > 0)
+ footer += " [more above]";
+ write_line(ANSI_ESCAPE1(ANSI_CTRL_FAINT), footer);
+
+ GetOutputStreamFileSP()->Lock() << frame;
+}
+
+void IOHandlerPicker::Run() {
+ if (!GetIsRealTerminal() || m_items.empty() || !EnterRawMode()) {
+ m_result.was_canceled = true;
+ SetIsDone(true);
+ return;
+ }
+
+ UpdateLayout();
+
+ // Hide the statusline before switching screens: its scroll region also
+ // applies to the alternate screen, and its cursor save/restore shares the
+ // slot that leaving the alternate screen restores.
+ m_debugger.HideStatusline();
+
+ // The alternate screen preserves the user's scrollback, and disabling
+ // auto-wrap clips lines wider than measured instead of wrapping them.
+ GetOutputStreamFileSP()->Lock()
+ << ANSI_CSI_ENTER_ALT_SCREEN ANSI_CSI_HIDE_CURSOR
+ ANSI_CSI_DISABLE_AUTO_WRAP;
+ Render();
+
+ while (!GetIsDone()) {
+ switch (ReadKey()) {
+ case Key::Up:
+ if (m_cursor_index > 0)
+ --m_cursor_index;
+ break;
+ case Key::Down:
+ if (m_cursor_index + 1 < m_items.size())
+ ++m_cursor_index;
+ break;
+ case Key::PageUp:
+ m_cursor_index -= std::min(m_cursor_index, GetViewportHeight());
+ break;
+ case Key::PageDown:
+ m_cursor_index =
+ std::min(m_cursor_index + GetViewportHeight(), m_items.size() - 1);
+ break;
+ case Key::Home:
+ m_cursor_index = 0;
+ break;
+ case Key::End:
+ m_cursor_index = m_items.size() - 1;
+ break;
+ case Key::Wakeup:
+ // Unless Cancel() woke us up, the terminal was resized.
+ if (!GetIsDone()) {
+ UpdateLayout();
+ // Rows may now be narrower than the previous frame, so wipe it.
+ GetOutputStreamFileSP()->Lock() << ANSI_CSI_ERASE_VIEWPORT;
+ }
+ break;
+ case Key::Space:
+ if (m_mode == PickerMode::MultiSelect)
+ m_checked.flip(m_cursor_index);
+ break;
+ case Key::Enter:
+ if (m_mode == PickerMode::SingleSelect) {
+ m_result.selected_indices.push_back(m_cursor_index);
+ } else {
+ for (unsigned i : m_checked.set_bits())
+ m_result.selected_indices.push_back(i);
+ }
+ SetIsDone(true);
+ break;
+ case Key::Escape:
+ Cancel();
+ break;
+ case Key::Unknown:
+ break;
+ }
+
+ if (!GetIsDone()) {
+ ScrollToCursor();
+ Render();
+ }
+ }
+
+ GetOutputStreamFileSP()->Lock()
+ << ANSI_CSI_ENABLE_AUTO_WRAP ANSI_CSI_SHOW_CURSOR
+ ANSI_CSI_LEAVE_ALT_SCREEN;
+ RestoreTerminalMode();
+ m_debugger.ShowStatusline();
+}
+
+void IOHandlerPicker::Cancel() {
+ // PopIOHandler() calls this after Run() returns. Keep its result.
+ if (GetIsDone())
+ return;
+ m_result.was_canceled = true;
+ SetIsDone(true);
+ WakeUp();
+}
+
+bool IOHandlerPicker::Interrupt() {
+ Cancel();
+ return true;
+}
+
+void IOHandlerPicker::GotEOF() { Cancel(); }
+
+void IOHandlerPicker::TerminalSizeChanged() { WakeUp(); }
+
+void IOHandlerPicker::WakeUp() {
+ if (!m_wakeup_pipe.CanWrite())
+ return;
+ char c = 0;
+ if (llvm::Expected<size_t> bytes = m_wakeup_pipe.Write(&c, 1); !bytes)
+ llvm::consumeError(bytes.takeError());
+}
diff --git a/lldb/source/Interpreter/CommandInterpreter.cpp b/lldb/source/Interpreter/CommandInterpreter.cpp
index 6d361470039fda..35f082d5214c3d 100644
--- a/lldb/source/Interpreter/CommandInterpreter.cpp
+++ b/lldb/source/Interpreter/CommandInterpreter.cpp
@@ -40,6 +40,9 @@
#include "Commands/CommandObjectSource.h"
#include "Commands/CommandObjectStats.h"
#include "Commands/CommandObjectTarget.h"
+#ifndef NDEBUG
+#include "Commands/CommandObjectTestPicker.h"
+#endif
#include "Commands/CommandObjectThread.h"
#include "Commands/CommandObjectTrace.h"
#include "Commands/CommandObjectType.h"
@@ -605,6 +608,9 @@ void CommandInterpreter::LoadCommandDictionary() {
REGISTER_COMMAND_OBJECT("version", CommandObjectVersion);
REGISTER_COMMAND_OBJECT("watchpoint", CommandObjectMultiwordWatchpoint);
REGISTER_COMMAND_OBJECT("language", CommandObjectLanguage);
+#ifndef NDEBUG
+ REGISTER_COMMAND_OBJECT("test-picker", CommandObjectTestPicker);
+#endif
// clang-format off
const char *break_regexes[][2] = {
diff --git a/lldb/test/API/iohandler/picker/TestIOHandlerPicker.py b/lldb/test/API/iohandler/picker/TestIOHandlerPicker.py
new file mode 100644
index 00000000000000..6b1ebab0dd2918
--- /dev/null
+++ b/lldb/test/API/iohandler/picker/TestIOHandlerPicker.py
@@ -0,0 +1,90 @@
+"""
+Tests for the IOHandlerPicker interactive widget.
+
+`test-picker` lists items in descending id order (5, 4, 3, 2, 1 for a count of
+5), and multi-select mode pre-selects ids 1, 4, 7, ...
+"""
+
+import lldb
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+from lldbsuite.test.lldbpexpect import PExpectTest
+
+
+ at skipIfAsan
+ at skipIfRemote
+ at skipIfWindows
+class IOHandlerPickerTest(PExpectTest):
+ arrow_down = "\x1b[B"
+ escape_key = "\x1b"
+
+ def start_picker(self, command, header, dimensions=(24, 120)):
+ # `test-picker` is only registered in builds with assertions enabled.
+ if not self.dbg.GetCommandInterpreter().CommandExists("test-picker"):
+ self.skipTest("test-picker requires a build with assertions")
+ self.launch(dimensions=dimensions)
+ # launch() runs "settings clear --all", which resets the terminal size.
+ height, width = dimensions
+ self.expect(f"settings set term-height {height}")
+ self.expect(f"settings set term-width {width}")
+ self.child.sendline(command)
+ self.child.expect_exact(header)
+
+ def finish(self, *expected):
+ for line in expected:
+ self.child.expect_exact(line)
+ self.expect_prompt()
+ self.quit()
+
+ def test_single_select_arrows(self):
+ """Navigate with arrow keys and select with Enter."""
+ self.start_picker("test-picker single 5", "Select an item:")
+ self.child.send(self.arrow_down)
+ self.child.send(self.arrow_down)
+ self.child.send("\r")
+ self.finish("selected: 3")
+
+ def test_single_select_vim_keys(self):
+ """Navigate with j/k and select with Enter."""
+ self.start_picker("test-picker single 5", "Select an item:")
+ self.child.send("jjk")
+ self.child.send("\r")
+ self.finish("selected: 4")
+
+ def test_home_end_tilde_sequences(self):
+ """Home and End also work as "ESC [ 1 ~" and "ESC [ 4 ~"."""
+ self.start_picker("test-picker single 5", "Select an item:")
+ # Home, End, then up: the second-to-last item.
+ self.child.send("jj\x1b[1~\x1b[4~k\r")
+ self.finish("selected: 2")
+
+ def test_single_select_cancel(self):
+ """ESC cancels without selecting anything."""
+ self.start_picker("test-picker single 5", "Select an item:")
+ self.child.send(self.escape_key)
+ self.finish("canceled")
+
+ def test_multi_select_toggle(self):
+ """Space toggles items and Enter confirms the checked set."""
+ self.start_picker("test-picker multi 5", "Toggle items:")
+ # Check 5, then uncheck the pre-selected 4. Pre-selected 1 stays.
+ self.child.send(" j ")
+ self.child.send("\r")
+ self.finish("selected: 5", "selected: 1")
+
+ def test_multi_select_cancel(self):
+ """ESC discards toggles."""
+ self.start_picker("test-picker multi 5", "Toggle items:")
+ self.child.send(" j ")
+ self.child.send(self.escape_key)
+ self.finish("canceled")
+
+ def test_small_terminal_scrolls(self):
+ """Moving past the viewport scrolls the list."""
+ self.start_picker(
+ "test-picker single 20", "Select an item:", dimensions=(10, 60)
+ )
+ self.child.send("j" * 6)
+ self.child.expect_exact("[more above]")
+ self.child.send("\r")
+ self.finish("selected: 14")
More information about the lldb-commits
mailing list