diff --git a/bot_bottle/cli/dashboard.py b/bot_bottle/cli/dashboard.py index 1b45ff9..a317108 100644 --- a/bot_bottle/cli/dashboard.py +++ b/bot_bottle/cli/dashboard.py @@ -368,8 +368,6 @@ def _picker_modal( """Modal agent picker. Type to filter; j/k or arrows to navigate; Enter to confirm; Esc to abort (first press clears filter if any, second press exits).""" - if not names: - return None selected = 0 query = "" while True: @@ -455,9 +453,13 @@ def _draw_picker_modal( list_start_row = 3 visible_rows = box_h - list_start_row - 1 if not filtered: + empty_message = ( + "(no agents configured)" + if not all_names else "(no agents match filter)" + ) win.addnstr( list_start_row, 2, - "(no agents match filter)", + empty_message, box_w - 4, curses.A_DIM, ) else: @@ -1154,6 +1156,8 @@ def _new_agent_flow( names = sorted(manifest.agents.keys()) picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now)) if picked is None: + if not names: + return "no agents configured; create ~/.bot-bottle/agents/*.md" return "agent start aborted" # Backend picker (issue #77): operator chooses docker / @@ -1401,8 +1405,10 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: def _get_manifest() -> Manifest: if manifest_cache[0] is None: - manifest_cache[0] = Manifest.resolve(USER_CWD) + manifest_cache[0] = Manifest.resolve(USER_CWD, missing_ok=True) return manifest_cache[0] + if not _get_manifest().bottles and not _get_manifest().agents: + status_line = "warning: no bot-bottle config/agents found; new-agent picker is empty" # First-tick guard: a brand-new dashboard finds any # pre-existing queue entries on its first poll; those # shouldn't ring the bell as if they just arrived. diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 220dbe0..619d13e 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -661,7 +661,7 @@ class Manifest: agents: Mapping[str, Agent] @classmethod - def resolve(cls, cwd: str) -> "Manifest": + def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest": """Walk the per-file manifest tree and build a Manifest. Layout (PRD 0011): @@ -674,6 +674,11 @@ class Manifest: warning and ignored — the filesystem layout IS the trust boundary. + If `missing_ok` is true, a missing `$HOME/.bot-bottle/` + returns an empty manifest instead of dying. This is for + passive UI surfaces like the dashboard, which can still + monitor already-running agents without launch config. + If `bot-bottle.json` exists alongside a missing `.bot-bottle/` directory at either side, dies with a clear pointer at the README's manifest section — the @@ -689,6 +694,8 @@ class Manifest: _check_stale_json(cwd_dir, cwd_md, "$CWD") if not home_md.is_dir(): + if missing_ok: + return cls.from_json_obj({"bottles": {}, "agents": {}}) die( f"no manifest found: {home_md} does not exist. " f"See README.md for the per-file Markdown layout " diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index e285044..209cb8e 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -5,6 +5,7 @@ from __future__ import annotations import tempfile import unittest from pathlib import Path +from unittest import mock from bot_bottle import supervise from bot_bottle.cli import dashboard @@ -144,6 +145,18 @@ class TestFilterAgents(unittest.TestCase): self.assertEqual(["beta", "echo"], out) +class TestDashboardManifestLoading(unittest.TestCase): + def test_new_agent_flow_empty_manifest_has_no_picker_entries(self): + manifest = dashboard.Manifest.from_json_obj({"bottles": {}, "agents": {}}) + with mock.patch("bot_bottle.cli.dashboard._picker_modal", return_value=None) as picker: + status = dashboard._new_agent_flow( + None, manifest, {}, [], tmux_state=None, # type: ignore[arg-type] + ) + picker.assert_called_once() + self.assertEqual([], picker.call_args.args[1]) + self.assertIn("no agents configured", status) + + class TestRunningCounts(unittest.TestCase): """Per-agent running-count surfaced in the picker so the operator sees `(N running)` before picking. Counts come from diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index fa05fab..03c86dc 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -280,6 +280,11 @@ class TestNoManifestDies(_ResolveCase): with self.assertRaises(Die): self.resolve() + def test_missing_ok_returns_empty_manifest(self): + m = Manifest.resolve(str(self.cwd_root), missing_ok=True) + self.assertEqual({}, dict(m.bottles)) + self.assertEqual({}, dict(m.agents)) + class TestUnknownBottleReferenceDies(_ResolveCase): """An agent file naming a bottle that doesn't exist on disk