Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 605a70408e | |||
| 832808ff9a |
+28
-3
@@ -33,6 +33,7 @@ from ..backend.docker.capability_apply import snapshot_transcript
|
|||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import Manifest
|
from ..manifest import Manifest
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
from ._common import PROG, USER_CWD, read_tty_line
|
||||||
|
from . import tui
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(argv: list[str]) -> int:
|
def cmd_start(argv: list[str]) -> int:
|
||||||
@@ -49,15 +50,39 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
"or 'docker'). Overrides the env var when set."
|
"or 'docker'). Overrides the env var when set."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
parser.add_argument(
|
||||||
|
"name",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="agent name defined in bot-bottle.json (omit to pick interactively)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = Manifest.resolve(USER_CWD)
|
||||||
|
|
||||||
|
agent_name: str | None = args.name
|
||||||
|
if agent_name is None:
|
||||||
|
agent_name = tui.filter_select(
|
||||||
|
sorted(manifest.agents.keys()),
|
||||||
|
title="Select agent",
|
||||||
|
)
|
||||||
|
if agent_name is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backend_name: str | None = 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
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
agent_name=args.name,
|
agent_name=agent_name,
|
||||||
copy_cwd=args.cwd,
|
copy_cwd=args.cwd,
|
||||||
user_cwd=USER_CWD,
|
user_cwd=USER_CWD,
|
||||||
)
|
)
|
||||||
@@ -65,7 +90,7 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
spec,
|
spec,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
remote_control=args.remote_control,
|
remote_control=args.remote_control,
|
||||||
backend_name=args.backend,
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""tui.py — minimal curses filter-select picker for CLI prompts.
|
||||||
|
|
||||||
|
Exposed surface:
|
||||||
|
|
||||||
|
filter_select(items, *, title="", tty_path="/dev/tty") -> str | None
|
||||||
|
|
||||||
|
Opens /dev/tty directly so the picker works even when stdout/stdin are
|
||||||
|
redirected. Returns the selected item or None on cancel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def filter_select(
|
||||||
|
items: list[str],
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
tty_path: str = "/dev/tty",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Render a filter-select picker over *items*.
|
||||||
|
|
||||||
|
Returns the selected item string, or ``None`` if the user cancelled
|
||||||
|
(Esc / ``q`` / Ctrl-C / Ctrl-D) or if the terminal is too small.
|
||||||
|
|
||||||
|
The picker opens *tty_path* directly so it works even when
|
||||||
|
stdout/stdin are redirected.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _run_picker(items, title=title, tty_fd=tty_fd)
|
||||||
|
finally:
|
||||||
|
tty_fd.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal implementation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_KEY_ESC = 27
|
||||||
|
_KEY_CTRL_C = 3
|
||||||
|
_KEY_CTRL_D = 4
|
||||||
|
_KEY_BACKSPACE_WIN = 8
|
||||||
|
_KEY_ENTER_ALT = 10
|
||||||
|
|
||||||
|
_CANCEL_KEYS = frozenset([_KEY_ESC, _KEY_CTRL_C, _KEY_CTRL_D, ord("q")])
|
||||||
|
|
||||||
|
|
||||||
|
def _run_picker(items: list[str], *, title: str, tty_fd) -> Optional[str]:
|
||||||
|
"""Drive a curses session on *tty_fd* and return the picked item."""
|
||||||
|
# newterm lets us run curses on an arbitrary fd rather than the
|
||||||
|
# process's controlling tty / stdout — crucial when stdout is piped.
|
||||||
|
old_term = os.environ.get("TERM", "xterm-256color")
|
||||||
|
os.environ.setdefault("TERM", "xterm-256color")
|
||||||
|
|
||||||
|
# Save / restore the real stdin/stdout so curses newterm can use tty_fd.
|
||||||
|
orig_stdin = sys.__stdin__
|
||||||
|
orig_stdout = sys.__stdout__
|
||||||
|
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
tty_text = io.TextIOWrapper(tty_fd, write_through=True)
|
||||||
|
sys.__stdin__ = tty_text # type: ignore[assignment]
|
||||||
|
sys.__stdout__ = tty_text # type: ignore[assignment]
|
||||||
|
|
||||||
|
# curses.wrapper calls initscr which honours sys.__stdin__ / __stdout__
|
||||||
|
# on some builds; use newterm where available.
|
||||||
|
screen = curses.initscr()
|
||||||
|
curses.noecho()
|
||||||
|
curses.cbreak()
|
||||||
|
screen.keypad(True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _picker_loop(screen, items, title=title)
|
||||||
|
finally:
|
||||||
|
screen.keypad(False)
|
||||||
|
curses.nocbreak()
|
||||||
|
curses.echo()
|
||||||
|
curses.endwin()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||||
|
sys.__stdout__ = orig_stdout # type: ignore[assignment]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_loop(screen, items: list[str], *, title: str) -> Optional[str]:
|
||||||
|
query = ""
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
filtered = _filter_items(items, query)
|
||||||
|
|
||||||
|
# Clamp cursor into the visible list.
|
||||||
|
if not filtered:
|
||||||
|
cursor = 0
|
||||||
|
elif cursor >= len(filtered):
|
||||||
|
cursor = len(filtered) - 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
_render(screen, filtered, cursor, query=query, title=title)
|
||||||
|
except curses.error:
|
||||||
|
# Terminal too small or write error — bail out.
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = screen.getch()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in _CANCEL_KEYS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
||||||
|
return filtered[cursor] if filtered else None
|
||||||
|
|
||||||
|
if key in (curses.KEY_UP, ord("k")):
|
||||||
|
if cursor > 0:
|
||||||
|
cursor -= 1
|
||||||
|
|
||||||
|
elif key in (curses.KEY_DOWN, ord("j")):
|
||||||
|
if cursor < len(filtered) - 1:
|
||||||
|
cursor += 1
|
||||||
|
|
||||||
|
elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
|
||||||
|
query = query[:-1]
|
||||||
|
# After narrowing the filter, keep cursor in range.
|
||||||
|
new_filtered = _filter_items(items, query)
|
||||||
|
if cursor >= len(new_filtered):
|
||||||
|
cursor = max(0, len(new_filtered) - 1)
|
||||||
|
|
||||||
|
elif 32 <= key <= 126:
|
||||||
|
# Printable ASCII — append to query and reset cursor so the
|
||||||
|
# top of the newly-filtered list is selected.
|
||||||
|
query += chr(key)
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_items(items: list[str], query: str) -> list[str]:
|
||||||
|
if not query:
|
||||||
|
return list(items)
|
||||||
|
q = query.lower()
|
||||||
|
return [i for i in items if q in i.lower()]
|
||||||
|
|
||||||
|
|
||||||
|
def _render(screen, filtered: list[str], cursor: int, *, query: str, title: str) -> None:
|
||||||
|
screen.erase()
|
||||||
|
rows, cols = screen.getmaxyx()
|
||||||
|
min_rows = 5
|
||||||
|
|
||||||
|
if rows < min_rows:
|
||||||
|
raise curses.error("terminal too small")
|
||||||
|
|
||||||
|
row = 0
|
||||||
|
|
||||||
|
if title and row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
filter_label = f"Filter: {query}"
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, filter_label[:cols - 1])
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
sep = "─" * min(cols - 1, 40)
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, sep)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
list_start = row
|
||||||
|
# Reserve two rows for separator + help line at bottom.
|
||||||
|
list_rows = rows - list_start - 2
|
||||||
|
if list_rows < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Scroll window: keep cursor visible.
|
||||||
|
scroll = max(0, cursor - list_rows + 1)
|
||||||
|
visible = filtered[scroll: scroll + list_rows]
|
||||||
|
|
||||||
|
for idx, item in enumerate(visible):
|
||||||
|
abs_idx = scroll + idx
|
||||||
|
attr = curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL
|
||||||
|
prefix = "> " if abs_idx == cursor else " "
|
||||||
|
line = (prefix + item)[:cols - 1]
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, line, attr)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
if row < rows - 1:
|
||||||
|
_addstr_safe(screen, row, 0, sep)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
help_line = "[↑↓/jk] move [Enter] select [Esc/q] cancel"
|
||||||
|
if row < rows:
|
||||||
|
_addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1])
|
||||||
|
|
||||||
|
screen.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _addstr_safe(screen, row: int, col: int, text: str, attr: int = curses.A_NORMAL) -> None:
|
||||||
|
try:
|
||||||
|
screen.addstr(row, col, text, attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# PRD 0049: Named / Labelled Agents
|
|
||||||
|
|
||||||
- **Status:** Draft
|
|
||||||
- **Author:** didericis
|
|
||||||
- **Created:** 2026-06-03
|
|
||||||
- **Issue:** #171
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
At agent launch time, prompt the operator for a short human-readable label
|
|
||||||
(defaulting to the manifest agent key) and an optional color from the 16-color
|
|
||||||
ANSI palette. Store both in the bottle's `metadata.json`. Display the label —
|
|
||||||
rendered in the chosen color — in the dashboard's active-agents pane, replacing
|
|
||||||
the bare manifest key. Inject the label and color into the in-container
|
|
||||||
`claude.json` as `name` / `color` so Claude Code can surface them in its own
|
|
||||||
harness when upstream support lands.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The dashboard's agents pane identifies each running instance by its manifest
|
|
||||||
agent key (e.g., `implementer`) plus a random slug suffix. When an operator
|
|
||||||
runs three `implementer` bottles simultaneously — one each for three different
|
|
||||||
repos — the pane shows:
|
|
||||||
|
|
||||||
```
|
|
||||||
[docker] a3f9 implementer started 14:02:11 [egress,pipelock]
|
|
||||||
[docker] b81c implementer started 14:03:45 [egress,pipelock]
|
|
||||||
[docker] d220 implementer started 14:05:01 [egress,pipelock]
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no way to tell which bottle is working on which task without attaching
|
|
||||||
to each one in turn. The slug is opaque; the manifest key is shared. Operators
|
|
||||||
working a multi-bottle session resort to keeping a mental map of slug→task,
|
|
||||||
which breaks the moment they switch windows.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
1. After the operator selects an agent name (dashboard picker or CLI argument),
|
|
||||||
they are prompted for a label. The prompt suggests the manifest key as the
|
|
||||||
default; pressing Enter (or providing no input) accepts it. The label may
|
|
||||||
contain any printable characters up to 64 bytes.
|
|
||||||
2. After the label prompt, the operator is optionally prompted for a color from
|
|
||||||
the 16-color ANSI palette (names: `black`, `red`, `green`, `yellow`, `blue`,
|
|
||||||
`magenta`, `cyan`, `white`, `bright-black`, `bright-red`, `bright-green`,
|
|
||||||
`bright-yellow`, `bright-blue`, `bright-magenta`, `bright-cyan`,
|
|
||||||
`bright-white`). Pressing Enter without a selection skips color entirely.
|
|
||||||
3. `label` and `color` are stored in `BottleMetadata` and written to the
|
|
||||||
bottle's `metadata.json`. Both fields default to `""` (empty / unset).
|
|
||||||
4. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
|
|
||||||
from `metadata.json`.
|
|
||||||
5. `_format_agent_row` uses the label when non-empty (falling back to
|
|
||||||
`agent_name`). If a non-empty color is set and the terminal supports it, the
|
|
||||||
label substring is rendered in that color.
|
|
||||||
6. `BottleSpec` carries `label` and `color`; the docker backend's `prepare`
|
|
||||||
step copies them into `BottleMetadata`.
|
|
||||||
7. `agent_provider.py` writes `label` → `"name"` and `color` → `"color"` into
|
|
||||||
the generated `claude.json`, alongside the existing fields. Fields are
|
|
||||||
omitted when empty.
|
|
||||||
8. The dashboard's `_new_agent_flow` (PRD 0020) includes the label+color step
|
|
||||||
between agent selection and the backend picker.
|
|
||||||
9. `cmd_start` (CLI) includes the label+color step after argument validation
|
|
||||||
and before prepare-with-preflight.
|
|
||||||
10. All existing unit tests stay green; no new tests are required for this
|
|
||||||
change (the label/color fields are thin plumbing with no branching logic
|
|
||||||
worth unit-testing beyond the already-tested metadata read/write path).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Showing the agent label inside the Claude Code TUI (status line, terminal
|
|
||||||
title, custom header). That requires upstream Claude Code / codex support.
|
|
||||||
Writing to `claude.json` is best-effort scaffolding for when that lands.
|
|
||||||
- Per-bottle color affecting anything outside the dashboard agents pane (e.g.,
|
|
||||||
proposal-pane highlights, log prefixes).
|
|
||||||
- Validating or constraining label content beyond the 64-byte printable cap.
|
|
||||||
- Persisting color-pair state across dashboard restarts (color pairs are
|
|
||||||
initialized fresh each session).
|
|
||||||
- Editing the label or color of an already-running bottle.
|
|
||||||
- Exposing label/color via `./cli.py list` (out of scope for v1; trivial to
|
|
||||||
add later since the field will be in metadata).
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
operator input
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
BottleSpec.label, BottleSpec.color
|
|
||||||
│
|
|
||||||
├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json
|
|
||||||
│
|
|
||||||
└─► agent_provider.py → claude.json {"name": label, "color": color}
|
|
||||||
(omitted when empty)
|
|
||||||
|
|
||||||
dashboard refresh
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
_format_agent_row → label (colored) in the row string
|
|
||||||
```
|
|
||||||
|
|
||||||
### BottleSpec changes
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottleSpec:
|
|
||||||
manifest: Manifest
|
|
||||||
agent_name: str
|
|
||||||
copy_cwd: bool
|
|
||||||
user_cwd: str
|
|
||||||
identity: str = ""
|
|
||||||
label: str = "" # operator-chosen display name; defaults to agent_name at render time
|
|
||||||
color: str = "" # one of the 16 ANSI color names, or "" for terminal default
|
|
||||||
```
|
|
||||||
|
|
||||||
`label` and `color` default to `""` so all existing callers remain valid with
|
|
||||||
no changes.
|
|
||||||
|
|
||||||
### BottleMetadata changes
|
|
||||||
|
|
||||||
Add two new fields with backward-compatible defaults:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class BottleMetadata:
|
|
||||||
identity: str
|
|
||||||
agent_name: str
|
|
||||||
cwd: str
|
|
||||||
copy_cwd: bool
|
|
||||||
started_at: str
|
|
||||||
compose_project: str
|
|
||||||
backend: str
|
|
||||||
label: str = ""
|
|
||||||
color: str = ""
|
|
||||||
```
|
|
||||||
|
|
||||||
`metadata.json` written by older bot-bottle versions won't have these keys;
|
|
||||||
`read_metadata` already uses `dict.get` with defaults, so existing slugs load
|
|
||||||
cleanly with `label=""`, `color=""`.
|
|
||||||
|
|
||||||
### ActiveAgent changes
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ActiveAgent:
|
|
||||||
backend_name: str
|
|
||||||
slug: str
|
|
||||||
agent_name: str
|
|
||||||
started_at: str
|
|
||||||
services: tuple[str, ...]
|
|
||||||
label: str = ""
|
|
||||||
color: str = ""
|
|
||||||
```
|
|
||||||
|
|
||||||
`enumerate_active()` copies `label` and `color` out of `BottleMetadata` when
|
|
||||||
constructing each `ActiveAgent`. The smolmachines backend gets the same
|
|
||||||
additions for symmetry; it reads from its own metadata path.
|
|
||||||
|
|
||||||
### Dashboard row rendering
|
|
||||||
|
|
||||||
`_format_agent_row` already falls through cleanly on missing fields. The
|
|
||||||
change is:
|
|
||||||
|
|
||||||
```python
|
|
||||||
display_name = a.label if a.label else a.agent_name
|
|
||||||
```
|
|
||||||
|
|
||||||
Color rendering uses the existing `_try_init_green()` pattern as a model.
|
|
||||||
A `_color_pair_for(color_name)` helper initialises a fresh curses color pair
|
|
||||||
for the requested named color and returns its attr (or 0 on failure). Each
|
|
||||||
unique color in the active agent list gets its own pair index. Color pairs are
|
|
||||||
allocated lazily and cached in a `dict[str, int]` that lives for the duration
|
|
||||||
of the dashboard session.
|
|
||||||
|
|
||||||
The 16 ANSI color name → curses constant mapping:
|
|
||||||
|
|
||||||
| Name | curses constant |
|
|
||||||
|------|----------------|
|
|
||||||
| `black` | `curses.COLOR_BLACK` |
|
|
||||||
| `red` | `curses.COLOR_RED` |
|
|
||||||
| `green` | `curses.COLOR_GREEN` |
|
|
||||||
| `yellow` | `curses.COLOR_YELLOW` |
|
|
||||||
| `blue` | `curses.COLOR_BLUE` |
|
|
||||||
| `magenta` | `curses.COLOR_MAGENTA` |
|
|
||||||
| `cyan` | `curses.COLOR_CYAN` |
|
|
||||||
| `white` | `curses.COLOR_WHITE` |
|
|
||||||
| `bright-*` | same constant + `curses.A_BOLD` |
|
|
||||||
|
|
||||||
Terminals that don't support color fall back to plain text (the helper returns
|
|
||||||
0, which ORed in is a no-op — same pattern as `_try_init_green`).
|
|
||||||
|
|
||||||
### Label + color prompt — dashboard
|
|
||||||
|
|
||||||
In `_new_agent_flow`, after `_picker_modal` returns a non-None name and before
|
|
||||||
`_backend_picker_modal`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
label, color = _label_color_modal(stdscr, default_label=picked)
|
|
||||||
```
|
|
||||||
|
|
||||||
`_label_color_modal` uses `curses.endwin()` → text-mode prompts → restore
|
|
||||||
(the same drop-and-resume pattern as the existing editor flow and preflight
|
|
||||||
Y/N). Two sequential prompts:
|
|
||||||
|
|
||||||
```
|
|
||||||
bot-bottle: agent label [implementer]: <operator types>
|
|
||||||
bot-bottle: color (red/green/blue/… or Enter to skip): <operator types>
|
|
||||||
```
|
|
||||||
|
|
||||||
Invalid color names are silently ignored (treated as empty). The function
|
|
||||||
returns `(label, color)` — both strings, both possibly `""`.
|
|
||||||
|
|
||||||
### Label + color prompt — CLI
|
|
||||||
|
|
||||||
In `cmd_start`, after argument parsing and before `_launch_bottle`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
label = _text_prompt_label(args.name)
|
|
||||||
color = _text_prompt_color()
|
|
||||||
```
|
|
||||||
|
|
||||||
`_text_prompt_label(default)` writes `"bot-bottle: agent label [{default}]: "`
|
|
||||||
to stderr and returns the stripped input (or `default` if blank).
|
|
||||||
`_text_prompt_color()` writes the color prompt and returns the stripped input
|
|
||||||
(or `""` if blank or invalid).
|
|
||||||
|
|
||||||
Both use `read_tty_line()` (already in `start.py`) for the read.
|
|
||||||
|
|
||||||
### Claude Code config injection
|
|
||||||
|
|
||||||
In `agent_provider.py`, where `claude_config.write_text(...)` is called,
|
|
||||||
expand the JSON dict conditionally:
|
|
||||||
|
|
||||||
```python
|
|
||||||
payload = {
|
|
||||||
"hasCompletedOnboarding": True,
|
|
||||||
"theme": "dark",
|
|
||||||
"bypassPermissionsModeAccepted": True,
|
|
||||||
"projects": claude_projects,
|
|
||||||
}
|
|
||||||
if spec.label:
|
|
||||||
payload["name"] = spec.label
|
|
||||||
if spec.color:
|
|
||||||
payload["color"] = spec.color
|
|
||||||
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
|
||||||
```
|
|
||||||
|
|
||||||
`spec` here is the `AgentProvisionSpec` (or equivalent) that `agent_provider`
|
|
||||||
already receives; it needs `label` and `color` threaded in from `BottleSpec`
|
|
||||||
through whatever plan/provision object the provider operates on.
|
|
||||||
|
|
||||||
## Implementation chunks
|
|
||||||
|
|
||||||
Two PRs, each independently mergeable.
|
|
||||||
|
|
||||||
### Chunk 1 — schema + storage
|
|
||||||
|
|
||||||
- Add `label: str = ""` and `color: str = ""` to `BottleSpec`,
|
|
||||||
`BottleMetadata`, and `ActiveAgent`.
|
|
||||||
- `docker/prepare.py`: copy `spec.label` / `spec.color` into `BottleMetadata`.
|
|
||||||
- `docker/enumerate.py`: copy `metadata.label` / `metadata.color` into
|
|
||||||
`ActiveAgent`.
|
|
||||||
- `agent_provider.py` (or the plan object it reads): thread label/color through
|
|
||||||
to `claude.json` write.
|
|
||||||
- Smolmachines backend: parallel changes to metadata read/write and
|
|
||||||
`ActiveAgent` construction.
|
|
||||||
- No prompt changes; no UI changes. All existing behavior is identical.
|
|
||||||
|
|
||||||
### Chunk 2 — prompts + display
|
|
||||||
|
|
||||||
- `start.py`: add `_text_prompt_label` and `_text_prompt_color`; call them in
|
|
||||||
`cmd_start` before `_launch_bottle`; pass `label` / `color` into `BottleSpec`.
|
|
||||||
- `dashboard.py`: add `_label_color_modal` (drop-and-resume); call it in
|
|
||||||
`_new_agent_flow`; pass label/color into `BottleSpec`; add
|
|
||||||
`_color_pair_for` helper; update `_format_agent_row` to use `a.label` with
|
|
||||||
color rendering.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
1. `./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.
|
||||||
|
2. `./cli.py start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
|
||||||
|
shows an interactive backend selector; the selected backend is used
|
||||||
|
exactly as if `--backend=<selected>` had been passed.
|
||||||
|
3. `./cli.py start <name> --backend=<b>` (both explicit) shows neither
|
||||||
|
screen — no behavioural change from today.
|
||||||
|
4. `./cli.py start` (no arguments, no env backend) shows the agent
|
||||||
|
selector first, then the backend selector.
|
||||||
|
5. The filter-select widget is a standalone utility
|
||||||
|
(`bot_bottle/cli/tui.py`) shared by both selectors.
|
||||||
|
6. Pressing `Ctrl-C` or `q` in either selector exits cleanly (exit 0).
|
||||||
|
7. The widget supports incremental filtering: typing narrows the list;
|
||||||
|
`Backspace` removes the last character; `↑`/`↓`/`j`/`k` move the
|
||||||
|
cursor; `Enter` confirms; `Esc`/`q` cancels.
|
||||||
|
8. Unit tests cover: filtering logic, cursor movement, confirm, cancel,
|
||||||
|
and the `cmd_start` dispatch (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`
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
1. **`tui.py` + tests.** Add `bot_bottle/cli/tui.py` with
|
||||||
|
`filter_select` and unit tests in `tests/unit/test_cli_tui.py`.
|
||||||
|
2. **Wire into `cmd_start` + tests.** Make `name` optional, add the
|
||||||
|
two-gate dispatch, extend `tests/unit/test_cli_start_selector.py`.
|
||||||
|
3. **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.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Unit: cmd_start selector dispatch (PRD 0051).
|
||||||
|
|
||||||
|
Tests that cmd_start calls filter_select when name / backend are absent,
|
||||||
|
skips them when both are explicit, and returns 0 on cancel.
|
||||||
|
|
||||||
|
All actual launch work is stubbed so no container is created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch, call
|
||||||
|
|
||||||
|
import bot_bottle.cli.start as start_mod
|
||||||
|
import bot_bottle.cli.tui as tui_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manifest(agent_names: list[str]):
|
||||||
|
manifest = MagicMock()
|
||||||
|
manifest.agents = {name: MagicMock() for name in agent_names}
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdStartSelector(unittest.TestCase):
|
||||||
|
"""Drive cmd_start with a minimal set of stubs."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Stub Manifest.resolve so no on-disk manifest is needed.
|
||||||
|
self._manifest = _make_manifest(["researcher", "implementer"])
|
||||||
|
self._resolve_patch = patch(
|
||||||
|
"bot_bottle.cli.start.Manifest.resolve",
|
||||||
|
return_value=self._manifest,
|
||||||
|
)
|
||||||
|
self._resolve_patch.start()
|
||||||
|
|
||||||
|
# Stub _launch_bottle so no real container work happens.
|
||||||
|
self._launch_patch = patch(
|
||||||
|
"bot_bottle.cli.start._launch_bottle",
|
||||||
|
return_value=0,
|
||||||
|
)
|
||||||
|
self._launch_mock = self._launch_patch.start()
|
||||||
|
|
||||||
|
# Stub filter_select to avoid opening /dev/tty.
|
||||||
|
self._tui_patch = patch.object(tui_mod, "filter_select")
|
||||||
|
self._tui_mock = self._tui_patch.start()
|
||||||
|
|
||||||
|
# Ensure BOT_BOTTLE_BACKEND is absent so the backend picker fires.
|
||||||
|
self._env_patch = patch.dict(os.environ, {}, clear=False)
|
||||||
|
self._env_patch.start()
|
||||||
|
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._resolve_patch.stop()
|
||||||
|
self._launch_patch.stop()
|
||||||
|
self._tui_patch.stop()
|
||||||
|
self._env_patch.stop()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Both explicit — no picker shown
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_both_explicit_skips_picker(self):
|
||||||
|
self._tui_mock.return_value = "researcher"
|
||||||
|
rc = start_mod.cmd_start(["--backend=docker", "researcher"])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self._tui_mock.assert_not_called()
|
||||||
|
self._launch_mock.assert_called_once()
|
||||||
|
_, kwargs = self._launch_mock.call_args
|
||||||
|
self.assertEqual("docker", kwargs["backend_name"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Agent absent → agent picker fires; backend explicit
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_agent_absent_shows_agent_picker(self):
|
||||||
|
self._tui_mock.return_value = "researcher"
|
||||||
|
rc = start_mod.cmd_start(["--backend=docker"])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self._tui_mock.assert_called_once()
|
||||||
|
call_kwargs = self._tui_mock.call_args
|
||||||
|
self.assertEqual(["implementer", "researcher"], call_kwargs[0][0])
|
||||||
|
self.assertIn("agent", call_kwargs[1]["title"].lower())
|
||||||
|
|
||||||
|
def test_agent_picker_cancel_returns_0(self):
|
||||||
|
self._tui_mock.return_value = None
|
||||||
|
rc = start_mod.cmd_start(["--backend=docker"])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self._launch_mock.assert_not_called()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Agent explicit, backend absent → backend picker fires
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_backend_absent_shows_backend_picker(self):
|
||||||
|
self._tui_mock.return_value = "docker"
|
||||||
|
rc = start_mod.cmd_start(["researcher"])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self._tui_mock.assert_called_once()
|
||||||
|
call_kwargs = self._tui_mock.call_args
|
||||||
|
self.assertIn("backend", call_kwargs[1]["title"].lower())
|
||||||
|
|
||||||
|
def test_backend_picker_cancel_returns_0(self):
|
||||||
|
self._tui_mock.return_value = None
|
||||||
|
rc = start_mod.cmd_start(["researcher"])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self._launch_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_bot_bottle_backend_env_skips_backend_picker(self):
|
||||||
|
os.environ["BOT_BOTTLE_BACKEND"] = "docker"
|
||||||
|
try:
|
||||||
|
rc = start_mod.cmd_start(["researcher"])
|
||||||
|
finally:
|
||||||
|
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self._tui_mock.assert_not_called()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Both absent → agent picker then backend picker
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_both_absent_shows_both_pickers_in_order(self):
|
||||||
|
self._tui_mock.side_effect = ["researcher", "docker"]
|
||||||
|
rc = start_mod.cmd_start([])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self.assertEqual(2, self._tui_mock.call_count)
|
||||||
|
first_title = self._tui_mock.call_args_list[0][1]["title"].lower()
|
||||||
|
second_title = self._tui_mock.call_args_list[1][1]["title"].lower()
|
||||||
|
self.assertIn("agent", first_title)
|
||||||
|
self.assertIn("backend", second_title)
|
||||||
|
|
||||||
|
def test_both_absent_agent_cancel_skips_backend_picker(self):
|
||||||
|
self._tui_mock.side_effect = [None]
|
||||||
|
rc = start_mod.cmd_start([])
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self.assertEqual(1, self._tui_mock.call_count)
|
||||||
|
self._launch_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Unit tests for bot_bottle.cli.tui — filter_select internals.
|
||||||
|
|
||||||
|
We test the pure-Python logic (_filter_items, cursor movement, confirm,
|
||||||
|
cancel) by exercising the internal helpers directly, without spinning up
|
||||||
|
a real curses session (which requires a TTY).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from bot_bottle.cli.tui import _filter_items, filter_select
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterItems(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.items = ["researcher", "implementer", "codex-researcher", "reviewer"]
|
||||||
|
|
||||||
|
def test_empty_query_returns_all(self):
|
||||||
|
self.assertEqual(self.items, _filter_items(self.items, ""))
|
||||||
|
|
||||||
|
def test_query_filters_case_insensitively(self):
|
||||||
|
result = _filter_items(self.items, "RESEARCH")
|
||||||
|
self.assertEqual(["researcher", "codex-researcher"], result)
|
||||||
|
|
||||||
|
def test_no_match_returns_empty(self):
|
||||||
|
self.assertEqual([], _filter_items(self.items, "zzz"))
|
||||||
|
|
||||||
|
def test_partial_match(self):
|
||||||
|
result = _filter_items(self.items, "impl")
|
||||||
|
self.assertEqual(["implementer"], result)
|
||||||
|
|
||||||
|
def test_empty_items_returns_empty(self):
|
||||||
|
self.assertEqual([], _filter_items([], "foo"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterSelectEmptyItems(unittest.TestCase):
|
||||||
|
def test_returns_none_for_empty_list(self):
|
||||||
|
# No TTY needed — the short-circuit fires before opening tty.
|
||||||
|
result = filter_select([], title="Pick one", tty_path="/dev/null")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_none_when_tty_unavailable(self):
|
||||||
|
# /nonexistent is guaranteed to not open.
|
||||||
|
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user