- Add bot_bottle/cli/tui.py: curses filter-select picker that opens /dev/tty directly so it works with redirected stdout/stdin - Make `name` positional optional (nargs="?") in cmd_start; show agent picker when absent - Show backend picker when no --backend flag and BOT_BOTTLE_BACKEND is unset; skip when either is explicit or the env var is present - Add tests/unit/test_cli_tui.py covering _filter_items logic and short-circuit paths (empty list, unavailable tty) - Add tests/unit/test_cli_start_selector.py covering all four dispatch combinations (both explicit, agent-absent, backend-absent, both-absent) and cancel semantics - Activate PRD 0051
5.6 KiB
PRD 0051: Launch selector
- Status: Active
- Author: claude
- Created: 2026-06-04
- Issue: #185
Summary
When ./cli.py start is run without an agent name, or without a backend
explicitly specified, the user currently gets an argparse error (missing
positional) or falls through to the docker default silently. This PRD
adds a terminal UI that appears in those gaps: a filter-select screen
built with curses that lets the operator pick the agent and/or backend
interactively rather than memorising names or consulting ./cli.py list.
Problem
With the dashboard removed (PRD 0049), starting an agent from memory is the only path. The operator must know the exact agent name and type it as a positional argument. For infrequent users or large manifests this is friction. A picker that appears automatically when the name is absent closes the gap with minimal ceremony.
The same logic applies to backends: the operator rarely wants to specify
--backend explicitly, but when they do they need to know the set of
registered names. A picker on an empty --backend makes the choice
visible.
Goals / Success Criteria
./cli.py start(no arguments) shows an interactive agent selector; the selected name is used exactly as if it had been passed on the command line../cli.py start <name>(no--backend, noBOT_BOTTLE_BACKEND) shows an interactive backend selector; the selected backend is used exactly as if--backend=<selected>had been passed../cli.py start <name> --backend=<b>(both explicit) shows neither screen — no behavioural change from today../cli.py start(no arguments, no env backend) shows the agent selector first, then the backend selector.- The filter-select widget is a standalone utility
(
bot_bottle/cli/tui.py) shared by both selectors. - Pressing
Ctrl-Corqin either selector exits cleanly (exit 0). - The widget supports incremental filtering: typing narrows the list;
Backspaceremoves the last character;↑/↓/j/kmove the cursor;Enterconfirms;Esc/qcancels. - Unit tests cover: filtering logic, cursor movement, confirm, cancel,
and the
cmd_startdispatch (agent-absent, backend-absent, both-explicit, both-absent).
Non-goals
- The TUI is not a general-purpose picker exposed as a public API; it is an internal CLI utility.
- No mouse support.
- No pagination beyond what fits in the terminal window (scroll via cursor movement is sufficient for typical agent counts).
- No multi-select; exactly one item is chosen per invocation.
- No changes to
./cli.py resume,./cli.py list, or any other subcommand.
Design
bot_bottle/cli/tui.py — filter_select
def filter_select(
items: list[str],
*,
title: str = "",
tty_path: str = "/dev/tty",
) -> str | None:
"""Render a filter-select picker over the items list.
Returns the selected item string, or None if the user cancelled
(Esc / q / Ctrl-C / Ctrl-D).
Opens /dev/tty directly so the picker works even when stdout/stdin
are redirected — same pattern as `read_tty_line`.
"""
The widget renders to the tty file descriptor opened via curses.initscr
(or curses.newterm on the tty fd so stdout remains clean for callers
that pipe ./cli.py).
Layout (full-width, minimal):
Select agent (title, top line)
Filter: <query>_ (filter line)
─────────────────────────────
> researcher
implementer
codex-researcher
...
─────────────────────────────
[↑↓/jk] move [Enter] select [Esc/q] cancel
- Lines below the filter are the filtered items; the cursor (
>) marks the selection. - The list re-renders on every keypress.
- Terminal resize is not handled (SIGWINCH); if the window is too small the picker exits with None.
Changes to cmd_start
name changes from a required positional to an optional one
(nargs="?"). The post-parse block checks:
agent_name = args.name
if agent_name is None:
manifest = Manifest.resolve(USER_CWD)
agent_name = tui.filter_select(
sorted(manifest.agents.keys()),
title="Select agent",
)
if agent_name is None:
return 0 # user cancelled
backend_name = args.backend
if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ:
backend_name = tui.filter_select(
list(known_backend_names()),
title="Select backend",
)
if backend_name is None:
return 0 # user cancelled
The manifest object is resolved before the backend selection so the
agent picker can populate itself from the real manifest. The same
manifest is passed to BottleSpec; it is not resolved a second time.
/dev/tty isolation
filter_select opens /dev/tty and feeds it as the input file to
curses.wrapper-equivalent code (using curses.newterm to avoid
clobbering the caller's stdout/stderr). This keeps the picker
composable — callers can pipe ./cli.py output without the curses
draw sequences contaminating the pipe.
Implementation chunks
tui.py+ tests. Addbot_bottle/cli/tui.pywithfilter_selectand unit tests intests/unit/test_cli_tui.py.- Wire into
cmd_start+ tests. Makenameoptional, add the two-gate dispatch, extendtests/unit/test_cli_start_selector.py. - Activate PRD 0051. Flip Status Draft → Active in the same commit that lands the implementation.
Open questions
None. Scope is fully determined by the issue description.