[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