Files
crewAI/lib/cli/src/crewai_cli/tui_picker.py
Vidit Ostwal d20845f0a3 feat: add platform tools to JSON crew wizard (#7384)
* feat: support platform tools in JSON crews

* style: clarify platform integration labels

* fix: avoid repeating tool picker title

* fix(platform): surface JSON tool discovery errors
2026-09-11 12:13:55 -07:00

421 lines
12 KiB
Python

"""Arrow-key interactive pickers for CLI prompts."""
from __future__ import annotations
from contextlib import suppress
import sys
from typing import overload
import click
# CrewAI brand: primary=#FF5A50 (coral), teal=#1F7982
_CORAL = "\033[38;2;255;90;80m" # #FF5A50
_TEAL = "\033[38;2;31;121;130m" # #1F7982
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RESET = "\033[0m"
_HIDE_CURSOR = "\033[?25l"
_SHOW_CURSOR = "\033[?25h"
def _is_interactive() -> bool:
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except Exception:
return False
def _read_key() -> str:
if sys.platform == "win32":
import msvcrt
ch = msvcrt.getwch()
if ch in ("\x00", "\xe0"):
ch2 = msvcrt.getwch()
return {"H": "up", "P": "down"}.get(ch2, "")
if ch == "\r":
return "enter"
if ch == " ":
return "space"
if ch == "\x03":
raise KeyboardInterrupt
return ch
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
seq = sys.stdin.read(2)
if seq == "[A":
return "up"
if seq == "[B":
return "down"
return "esc"
if ch in ("\r", "\n"):
return "enter"
if ch == " ":
return "space"
if ch == "\x03":
raise KeyboardInterrupt
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _clear_lines(n: int) -> None:
sys.stdout.write(f"\033[{n}A")
for _ in range(n):
sys.stdout.write("\033[2K\n")
sys.stdout.write(f"\033[{n}A")
sys.stdout.flush()
def _draw_single(labels: list[str], cursor: int, *, clear: bool = False) -> None:
total = len(labels)
if clear:
sys.stdout.write(f"\033[{total}A")
for i, label in enumerate(labels):
if i == cursor:
sys.stdout.write(f"\033[2K {_CORAL}{_RESET} {_BOLD}{label}{_RESET}\n")
else:
sys.stdout.write(f"\033[2K {label}\n")
sys.stdout.flush()
def _draw_multi(
labels: list[str],
cursor: int,
selected: set[int],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
clear: bool = False,
) -> None:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
hint_text = "↑↓ navigate, space toggle, enter confirm"
if action_indices:
hint_text = "↑↓ navigate, space toggle, enter confirm, ▸ rows expand/collapse"
hint = f" {_DIM}{hint_text}{_RESET}"
total = len(labels) + 1
if clear:
sys.stdout.write(f"\033[{total}A")
sys.stdout.write(f"\033[2K{hint}\n")
for i, label in enumerate(labels):
if i in separator_indices:
sys.stdout.write(f"\033[2K {_TEAL}{label}{_RESET}\n")
continue
if i in action_indices:
check = " "
elif i in selected:
check = f"{_CORAL}[x]{_RESET}"
else:
check = "[ ]"
arrow = f"{_CORAL}{_RESET} " if i == cursor else " "
bold = f"{_BOLD}{label}{_RESET}" if i == cursor else label
sys.stdout.write(f"\033[2K {arrow}{check} {bold}\n")
sys.stdout.flush()
def _arrow_select_one(labels: list[str]) -> int:
cursor = 0
total = len(labels)
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_single(labels, cursor)
while True:
key = _read_key()
if key == "up" and cursor > 0:
cursor -= 1
_draw_single(labels, cursor, clear=True)
elif key == "down" and cursor < total - 1:
cursor += 1
_draw_single(labels, cursor, clear=True)
elif key == "enter":
_clear_lines(total)
return cursor
elif key in ("esc", "q"):
_clear_lines(total)
return -1
finally:
sys.stdout.write(_SHOW_CURSOR)
sys.stdout.flush()
def _arrow_select_multi(
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> tuple[list[int], int | None]:
total = len(labels)
selected: set[int] = set(preselected or ())
action_indices = action_indices or set()
separator_indices = separator_indices or set()
if initial_cursor is not None and 0 <= initial_cursor < total:
cursor = initial_cursor
else:
cursor = _first_selectable_index(total, separator_indices)
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
)
while True:
key = _read_key()
if key == "up":
cursor = _next_selectable_index(cursor, -1, total, separator_indices)
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "down":
cursor = _next_selectable_index(cursor, 1, total, separator_indices)
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "space":
if cursor in action_indices:
_clear_lines(total + 1)
return sorted(selected), cursor
selected ^= {cursor}
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "enter":
_clear_lines(total + 1)
if cursor in action_indices:
return sorted(selected), cursor
return sorted(selected), None
elif key in ("esc", "q"):
_clear_lines(total + 1)
return sorted(selected), None
finally:
sys.stdout.write(_SHOW_CURSOR)
sys.stdout.flush()
def _numbered_select(labels: list[str]) -> int:
for idx, label in enumerate(labels, 1):
click.echo(f" {idx}. {label}")
click.echo()
while True:
choice = click.prompt(" Select", type=str, default="1")
if choice.lower() == "q":
return -1
try:
num = int(choice)
if 1 <= num <= len(labels):
return num - 1
except ValueError:
# Non-numeric input falls through to the shared error message.
pass
click.secho(f" Invalid choice. Enter 1-{len(labels)}.", fg="red")
def _numbered_select_multi(
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
) -> tuple[list[int], int | None]:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
numbered_indices: list[int] = []
for idx, label in enumerate(labels):
if idx in separator_indices:
click.secho(f" {label}", fg="cyan")
continue
numbered_indices.append(idx)
click.echo(f" {len(numbered_indices)}. {label}")
click.echo()
raw = click.prompt(
" Select (comma-separated numbers, or empty to skip)",
default="",
show_default=False,
)
if not raw.strip():
return sorted(preselected or ()), None
indices: list[int] = list(preselected or ())
for part in raw.split(","):
with suppress(ValueError):
num = int(part.strip())
if 1 <= num <= len(numbered_indices):
idx = numbered_indices[num - 1]
if idx in action_indices:
return sorted(set(indices)), idx
indices.append(idx)
return sorted(set(indices)), None
def _first_selectable_index(total: int, separator_indices: set[int]) -> int:
for idx in range(total):
if idx not in separator_indices:
return idx
return 0
def _next_selectable_index(
cursor: int,
direction: int,
total: int,
separator_indices: set[int],
) -> int:
next_cursor = cursor + direction
while 0 <= next_cursor < total:
if next_cursor not in separator_indices:
return next_cursor
next_cursor += direction
return cursor
# ── Public API ──────────────────────────────────────────────────
def pick(title: str, options: list[tuple[str, str]]) -> str | None:
"""Arrow-key single-select picker.
Args:
title: Header text.
options: List of ``(value, description)`` tuples.
Returns:
The *value* of the selected option, or ``None`` if cancelled.
"""
labels = [f"{value:<12s} {desc}" for value, desc in options]
click.echo()
click.secho(f" {title}", fg="cyan", bold=True)
click.echo()
if _is_interactive():
try:
idx = _arrow_select_one(labels)
except Exception:
idx = _numbered_select(labels)
else:
idx = _numbered_select(labels)
if idx < 0:
return None
value, _desc = options[idx]
click.secho(f"{value}", fg="green")
return value
def pick_one(title: str, labels: list[str]) -> int:
"""Arrow-key single-select from plain labels.
Returns:
Selected index, or ``-1`` if cancelled.
"""
click.echo()
click.secho(f" {title}", fg="cyan")
if _is_interactive():
try:
return _arrow_select_one(labels)
except Exception:
return _numbered_select(labels)
return _numbered_select(labels)
@overload
def pick_many(
title: str,
labels: list[str],
*,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> list[int]: ...
@overload
def pick_many(
title: str,
labels: list[str],
*,
action_indices: set[int],
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> tuple[list[int], int | None]: ...
def pick_many(
title: str,
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> list[int] | tuple[list[int], int | None]:
"""Arrow-key multi-select with checkboxes.
Returns:
Sorted list of selected indices, or ``(indices, action_index)`` when
``action_indices`` is provided.
"""
if title:
click.echo()
click.secho(f" {title}", fg="cyan")
if _is_interactive():
try:
selected, action = _arrow_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
initial_cursor=initial_cursor,
)
except Exception:
selected, action = _numbered_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
)
else:
selected, action = _numbered_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
)
if action_indices is None:
return selected
return selected, action