docs(prd): redesign label+color prompt as a curses modal window
lint / lint (push) Successful in 1m44s

- Single modal with two steps (label then color) instead of
  bare text prompts dropped to terminal
- Default label is <agent_name>-<slug_suffix>; first keystroke
  replaces the pre-fill rather than appending to it
- Color step shows a navigable list with live color preview;
  (none) selected by default; Esc skips
- Modal lives in tui.py and is shared between supervisor flow
  and cmd_start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:01:11 -04:00
parent 299579ab7b
commit 39e0976ace
+118 -93
View File
@@ -7,17 +7,19 @@
## Summary ## Summary
At agent launch time, prompt the operator for a short human-readable label At agent launch time, present the operator with a curses modal to optionally
(defaulting to the manifest agent key) and an optional color from the 16-color set a human-readable label and color for the agent before it launches. The
ANSI palette. Store both in the bottle's `metadata.json`. Display the label — modal pre-fills the label with the current agent name pattern (e.g.
rendered in the chosen color — in the dashboard's active-agents pane, replacing `implementer-a3f9`) and leaves color unset; Enter with no changes accepts
the bare manifest key. Inject the label and color into the in-container those defaults. Store both in the bottle's `metadata.json`. Display the label —
`claude.json` as `name` / `color` so Claude Code can surface them in its own rendered in the chosen color — in the supervisor's active-agents pane,
harness when upstream support lands. 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 ## Problem
The dashboard's agents pane identifies each running instance by its manifest The supervisor's agents pane identifies each running instance by its manifest
agent key (e.g., `implementer`) plus a random slug suffix. When an operator agent key (e.g., `implementer`) plus a random slug suffix. When an operator
runs three `implementer` bottles simultaneously — one each for three different runs three `implementer` bottles simultaneously — one each for three different
repos — the pane shows: repos — the pane shows:
@@ -35,33 +37,34 @@ which breaks the moment they switch windows.
## Goals / Success Criteria ## Goals / Success Criteria
1. After the operator selects an agent name (dashboard picker or CLI argument), 1. After the operator selects an agent (supervisor picker or CLI argument), a
they are prompted for a label. The prompt suggests the manifest key as the curses modal appears before the backend picker / preflight. The modal
default; pressing Enter (or providing no input) accepts it. The label may pre-fills the label with `<agent_name>-<slug_suffix>` (the same pattern
contain any printable characters up to 64 bytes. currently shown in the agents pane). No color is pre-selected.
2. After the label prompt, the operator is optionally prompted for a color from 2. In the modal, any printable keystroke immediately replaces the pre-filled
the 16-color ANSI palette (names: `black`, `red`, `green`, `yellow`, `blue`, label and starts building the new name. Backspace edits normally. Enter
`magenta`, `cyan`, `white`, `bright-black`, `bright-red`, `bright-green`, at any point confirms — accepting the pre-fill if nothing was typed, or
`bright-yellow`, `bright-blue`, `bright-magenta`, `bright-cyan`, the in-progress text otherwise.
`bright-white`). Pressing Enter without a selection skips color entirely. 3. After the label field is confirmed, the modal presents color selection:
3. `label` and `color` are stored in `BottleMetadata` and written to the a list of the 16 ANSI color names the operator can navigate with arrow
keys, or Enter with no selection to skip color entirely.
4. `label` and `color` are stored in `BottleMetadata` and written to the
bottle's `metadata.json`. Both fields default to `""` (empty / unset). bottle's `metadata.json`. Both fields default to `""` (empty / unset).
4. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them 5. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
from `metadata.json`. from `metadata.json`.
5. `_format_agent_row` uses the label when non-empty (falling back to 6. The supervisor's 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 `agent_name`). If a non-empty color is set and the terminal supports it,
label substring is rendered in that color. the label substring is rendered in that color.
6. `BottleSpec` carries `label` and `color`; the docker backend's `prepare` 7. `BottleSpec` carries `label` and `color`; both backends' `prepare` steps
step copies them into `BottleMetadata`. copy them into `BottleMetadata`.
7. `ClaudeAgentProvider.provision_plan()` in 8. `ClaudeAgentProvider.provision_plan()` writes `label``"name"` and
`bot_bottle/contrib/claude/agent_provider.py` writes `label``"name"` and `color``"color"` into the generated `claude.json`. Fields are omitted
`color``"color"` into the generated `claude.json`, alongside the existing when empty.
fields. Fields are omitted when empty. 9. The supervisor's `_new_agent_flow` includes the modal between agent
8. The dashboard's `_new_agent_flow` (PRD 0020) includes the label+color step selection and the backend picker.
between agent selection and the backend picker. 10. `cmd_start` (CLI) shows the same modal (via the shared `tui` module)
9. `cmd_start` (CLI) includes the label+color step after argument validation before `_launch_bottle`; passes `label` / `color` into `BottleSpec`.
and before prepare-with-preflight. 11. All existing unit tests stay green; no new tests are required for this
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 change (the label/color fields are thin plumbing with no branching logic
worth unit-testing beyond the already-tested metadata read/write path). worth unit-testing beyond the already-tested metadata read/write path).
@@ -70,37 +73,36 @@ which breaks the moment they switch windows.
- Showing the agent label inside the Claude Code TUI (status line, terminal - Showing the agent label inside the Claude Code TUI (status line, terminal
title, custom header). That requires upstream Claude Code / codex support. title, custom header). That requires upstream Claude Code / codex support.
Writing to `claude.json` is best-effort scaffolding for when that lands. Writing to `claude.json` is best-effort scaffolding for when that lands.
- Per-bottle color affecting anything outside the dashboard agents pane (e.g., - Per-bottle color affecting anything outside the supervisor agents pane.
proposal-pane highlights, log prefixes).
- Validating or constraining label content beyond the 64-byte printable cap. - Validating or constraining label content beyond the 64-byte printable cap.
- Persisting color-pair state across dashboard restarts (color pairs are - Persisting color-pair state across supervisor restarts (color pairs are
initialized fresh each session). initialized fresh each session).
- Editing the label or color of an already-running bottle. - Editing the label or color of an already-running bottle.
- Exposing label/color via `./cli.py list` (out of scope for v1; trivial to - Exposing label/color via `./cli.py list` (out of scope for v1).
add later since the field will be in metadata).
## Design ## Design
### Data flow ### Data flow
``` ```
operator input operator input (modal)
BottleSpec.label, BottleSpec.color BottleSpec.label, BottleSpec.color
├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json ├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json
├─► smolmachines/prepare.py → BottleMetadata.label / .color → metadata.json
└─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color} └─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color}
(omitted when empty) (omitted when empty)
dashboard refresh supervisor refresh
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
_format_agent_row → label (colored) in the row string agent row → label (colored) in the row string
``` ```
### BottleSpec changes ### BottleSpec changes
@@ -158,12 +160,11 @@ class ActiveAgent:
`enumerate_active()` copies `label` and `color` out of `BottleMetadata` when `enumerate_active()` copies `label` and `color` out of `BottleMetadata` when
constructing each `ActiveAgent`. The smolmachines backend gets the same constructing each `ActiveAgent`. The smolmachines backend gets the same
additions for symmetry; it reads from its own metadata path. additions for symmetry.
### Dashboard row rendering ### Agent row rendering
`_format_agent_row` already falls through cleanly on missing fields. The The display name logic becomes:
change is:
```python ```python
display_name = a.label if a.label else a.agent_name display_name = a.label if a.label else a.agent_name
@@ -171,10 +172,9 @@ display_name = a.label if a.label else a.agent_name
Color rendering uses the existing `_try_init_green()` pattern as a model. 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 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 for the requested named color and returns its attr (or 0 on failure). Color
unique color in the active agent list gets its own pair index. Color pairs are pairs are allocated lazily and cached in a `dict[str, int]` that lives for
allocated lazily and cached in a `dict[str, int]` that lives for the duration the duration of the supervisor session.
of the dashboard session.
The 16 ANSI color name → curses constant mapping: The 16 ANSI color name → curses constant mapping:
@@ -191,44 +191,72 @@ The 16 ANSI color name → curses constant mapping:
| `bright-*` | same constant + `curses.A_BOLD` | | `bright-*` | same constant + `curses.A_BOLD` |
Terminals that don't support color fall back to plain text (the helper returns 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`). 0, which ORed in is a no-op).
### Label + color prompt — dashboard ### The label+color modal
In `_new_agent_flow`, after `_picker_modal` returns a non-None name and before A single curses modal (`name_color_modal` in `bot_bottle/cli/tui.py`) handles
`_backend_picker_modal`: both label and color in two sequential steps within the same window. It is
called identically from the supervisor flow and from `cmd_start`.
```python ```python
label, color = _label_color_modal(stdscr, default_label=picked) label, color = name_color_modal(default_label=f"{agent_name}-{slug_suffix}")
``` ```
`_label_color_modal` uses `curses.endwin()` → text-mode prompts → restore **Step 1 — label.** The window renders:
(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> Name agent
bot-bottle: color (red/green/blue/… or Enter to skip): <operator types> ──────────────────────────────────────
implementer-a3f9
──────────────────────────────────────
[any key] edit [Enter] confirm
``` ```
Invalid color names are silently ignored (treated as empty). The function The pre-filled text is shown in the input field. Any printable keystroke
returns `(label, color)` — both strings, both possibly `""`. immediately clears the pre-fill and starts a new name from that character
(first-keystroke-replaces semantics). Subsequent keystrokes append normally.
Backspace edits from the right. Enter confirms — accepting the pre-fill if
the field was never edited, or the typed text otherwise.
### Label + color prompt — CLI **Step 2 — color.** After confirming the label, the window transitions to:
In `cmd_start`, after argument parsing and before `_launch_bottle`: ```
Name agent
```python ──────────────────────────────────────
label = _text_prompt_label(args.name) implementer-a3f9 ← confirmed label
color = _text_prompt_color() ──────────────────────────────────────
Color (optional)
> (none)
red
green
blue
──────────────────────────────────────
[↑↓] move [Enter] select [Esc] skip
``` ```
`_text_prompt_label(default)` writes `"bot-bottle: agent label [{default}]: "` The list starts with `(none)` selected. Arrow keys move the cursor; Enter
to stderr and returns the stripped input (or `default` if blank). confirms the highlighted choice; Esc or `q` skips color (equivalent to
`_text_prompt_color()` writes the color prompt and returns the stripped input selecting `(none)`). Each color name in the list is rendered in its own color
(or `""` if blank or invalid). so the operator can preview the palette.
Both use `read_tty_line()` (already in `start.py`) for the read. The function returns `(label, color)` — both strings, `color` is `""` when
`(none)` is selected or the step is skipped.
### Slug suffix for the default label
The default label is `<agent_name>-<slug_suffix>`, where `slug_suffix` is the
last four characters of the slug (the same short hash shown in the agents
pane). Both the supervisor and `cmd_start` have access to the slug after
`bottle_identity()` is called; the default label is computed there and passed
to `name_color_modal`.
In `cmd_start` the slug is not yet known at the time the modal appears (it is
minted inside `prepare`). The modal is therefore called with
`default_label=args.name` (the manifest agent key) as a simpler fallback; the
supervisor path, which has the slug available from `_new_agent_flow`, uses the
full `<agent_name>-<slug_suffix>` form.
### Claude Code config injection ### Claude Code config injection
@@ -237,13 +265,11 @@ Per PRD 0050, the `claude.json` trust-marker file is written by
`bot_bottle/contrib/claude/agent_provider.py`. Add `label: str = ""` and `bot_bottle/contrib/claude/agent_provider.py`. Add `label: str = ""` and
`color: str = ""` keyword parameters to `provision_plan()` on both the `color: str = ""` keyword parameters to `provision_plan()` on both the
`AgentProvider` ABC and `ClaudeAgentProvider`, and to the `AgentProvider` ABC and `ClaudeAgentProvider`, and to the
`agent_provision_plan()` shim in `agent_provider.py`. The docker and `agent_provision_plan()` shim in `agent_provider.py`. Both `prepare.py`
smolmachines `prepare.py` modules pass `spec.label` / `spec.color` when modules pass `spec.label` / `spec.color`; other providers accept the params
calling `agent_provision_plan()`; other providers accept the params and ignore and ignore them.
them.
In `ClaudeAgentProvider.provision_plan()`, expand the JSON payload In `ClaudeAgentProvider.provision_plan()`:
conditionally:
```python ```python
payload = { payload = {
@@ -267,27 +293,26 @@ Two PRs, each independently mergeable.
- Add `label: str = ""` and `color: str = ""` to `BottleSpec`, - Add `label: str = ""` and `color: str = ""` to `BottleSpec`,
`BottleMetadata`, and `ActiveAgent`. `BottleMetadata`, and `ActiveAgent`.
- `docker/prepare.py`: copy `spec.label` / `spec.color` into `BottleMetadata`. - `docker/prepare.py` and `smolmachines/prepare.py`: copy `spec.label` /
- `docker/enumerate.py`: copy `metadata.label` / `metadata.color` into `spec.color` into `BottleMetadata`; pass them to `agent_provision_plan()`.
`ActiveAgent`. - `docker/enumerate.py` and smolmachines equivalent: copy `metadata.label` /
`metadata.color` into `ActiveAgent`.
- Add `label: str = ""` and `color: str = ""` keyword params to - Add `label: str = ""` and `color: str = ""` keyword params to
`AgentProvider.provision_plan()` (ABC), `ClaudeAgentProvider.provision_plan()` `AgentProvider.provision_plan()` (ABC), `ClaudeAgentProvider.provision_plan()`
(uses them in the `claude.json` write), and the `agent_provision_plan()` shim. (uses them in the `claude.json` write), and the `agent_provision_plan()` shim.
Other providers (`CodexAgentProvider`) accept the params and ignore them. `CodexAgentProvider` accepts the params and ignores them.
- `docker/prepare.py` and `smolmachines/prepare.py`: pass `spec.label` /
`spec.color` to `agent_provision_plan()`.
- Smolmachines backend: parallel changes to metadata read/write and
`ActiveAgent` construction.
- No prompt changes; no UI changes. All existing behavior is identical. - No prompt changes; no UI changes. All existing behavior is identical.
### Chunk 2 — prompts + display ### Chunk 2 — modal + display
- `start.py`: add `_text_prompt_label` and `_text_prompt_color`; call them in - `bot_bottle/cli/tui.py`: add `name_color_modal(default_label)` implementing
`cmd_start` before `_launch_bottle`; pass `label` / `color` into `BottleSpec`. the two-step curses window described above.
- `dashboard.py`: add `_label_color_modal` (drop-and-resume); call it in - Supervisor `_new_agent_flow`: call `name_color_modal` after agent selection,
`_new_agent_flow`; pass label/color into `BottleSpec`; add pass `label` / `color` into `BottleSpec`.
`_color_pair_for` helper; update `_format_agent_row` to use `a.label` with - `cmd_start`: call `name_color_modal(default_label=args.name)` before
color rendering. `_launch_bottle`; pass `label` / `color` into `BottleSpec`.
- Supervisor agent row: add `_color_pair_for` helper; update row rendering to
use `a.label` with color.
## Open questions ## Open questions