Skip to content

Commit d150f5b

Browse files
author
codejunkie99
committed
feat(harness-manager): add manage TUI menu loop (re-add cut from v0.9.0 narrow plan)
The narrow v0.9.0 plan cut the persistent TUI based on codex's "subcommands are more honest UX" framing. Reversing that cut at user request — TUI is now opt-in alongside the verb-style subcommands (the gh / k9s pattern: both surfaces, user picks). What ships: - `./install.sh manage` enters a clack-style menu loop with header pane showing project, brain stats, version, and per-adapter status glyphs (✓ green / ⚠ yellow / ✗ red). Loops until user picks Exit, hits `q`, or two consecutive Ctrl-C presses. - New ask_multiselect() widget in onboard_widgets.py — checkbox-style ◉/○ glyphs, ▸ cursor, space toggles current row, enter confirms, q cancels (used `q` not ESC because the existing POSIX get_key() blocks on lone ESC waiting for arrow-sequence's second byte). - 5 menu actions: Add (multi-select uninstalled adapters), Remove (multi-select installed adapters; per-adapter destructive confirm fires inside remove.py for safety colocation), Run doctor, Show status, Reconfigure preferences (drops into onboard.py --reconfigure). - Refuses non-tty cleanly with exit 2 + hint pointing at the subcommand verbs. Subcommands stay as the scriptable / CI surface. - Two-Ctrl-C-to-exit semantics; first Ctrl-C returns to menu with hint. Subcommands (`add`, `remove`, `doctor`, `status`) are unchanged — TUI just gives users a single entry point if they prefer interactive UX over remembering verbs. Bare-mode help now lists `manage` as an option. Test suite (41/41) still passes. TUI is opt-in and tty-gated, so no test-harness coupling needed for this PR; manual exercise required.
1 parent 220bdca commit d150f5b

3 files changed

Lines changed: 308 additions & 1 deletion

File tree

harness_manager/cli.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from . import __version__
2020

2121

22-
VERBS = {"add", "remove", "doctor", "status"}
22+
VERBS = {"add", "remove", "doctor", "status", "manage"}
2323

2424

2525
def _stack_root() -> Path:
@@ -163,6 +163,23 @@ def cmd_status(target: Path) -> int:
163163
return status_mod.show(target_root=target)
164164

165165

166+
def cmd_manage(target: Path) -> int:
167+
"""Open the persistent TUI menu for ongoing adapter management."""
168+
if not sys.stdin.isatty() or not sys.stdout.isatty():
169+
print(
170+
"error: manage is an interactive TUI; this shell is not a TTY.\n"
171+
"use the verb-style subcommands instead:\n"
172+
" ./install.sh add <adapter>\n"
173+
" ./install.sh remove <adapter>\n"
174+
" ./install.sh doctor\n"
175+
" ./install.sh status",
176+
file=sys.stderr,
177+
)
178+
return 2
179+
from . import manage_tui
180+
return manage_tui.run(target_root=target, stack_root=_stack_root())
181+
182+
166183
def cmd_bare(target: Path) -> int:
167184
"""`./install.sh` with no args.
168185
@@ -178,6 +195,8 @@ def cmd_bare(target: Path) -> int:
178195
print(" ./install.sh doctor # audit")
179196
print(" ./install.sh status # quick read-only view")
180197
print(" ./install.sh add <name> # install another adapter")
198+
print(" ./install.sh remove <name> # remove an adapter (with confirm)")
199+
print(" ./install.sh manage # interactive TUI for everything")
181200
return 2
182201

183202
installed = set(doc.get("adapters", {}).keys())
@@ -253,6 +272,9 @@ def main(argv: list[str] | None = None) -> int:
253272
if verb == "status":
254273
target = Path(rest[1]) if len(rest) >= 2 else Path.cwd()
255274
return cmd_status(target)
275+
if verb == "manage":
276+
target = Path(rest[1]) if len(rest) >= 2 else Path.cwd()
277+
return cmd_manage(target)
256278

257279
# Treat as adapter name (existing UX)
258280
adapter = first

harness_manager/manage_tui.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Persistent TUI menu loop for managing installed adapters.
2+
3+
Entry point: `./install.sh manage`. Re-entrant — the user picks an
4+
action, the action runs (with its own prompts as needed), then we
5+
return to the menu. Exits cleanly on `q`, `Exit` menu pick, or two
6+
consecutive Ctrl-C presses.
7+
8+
Reuses onboard_widgets.ask_select / ask_multiselect / ask_confirm
9+
for consistency with the existing wizard. No new UI primitives
10+
beyond the multi-select widget added at the same time.
11+
"""
12+
import os
13+
import shutil
14+
import signal
15+
import sys
16+
from pathlib import Path
17+
18+
# onboard_widgets and onboard_ui live at repo root, not under
19+
# harness_manager/. install.sh prepends repo root to PYTHONPATH so this
20+
# import works.
21+
import onboard_widgets as widgets # noqa: E402
22+
from onboard_ui import ( # noqa: E402
23+
R, B, GREEN, ORANGE, MUTED, WHITE, BLUE, PURPLE, BAR,
24+
)
25+
26+
from . import doctor as doctor_mod
27+
from . import install as install_mod
28+
from . import remove as remove_mod
29+
from . import schema as schema_mod
30+
from . import state as state_mod
31+
from . import status as status_mod
32+
from . import __version__
33+
34+
35+
# ---- header pane -----------------------------------------------------
36+
37+
def _render_header(target_root: Path) -> None:
38+
"""One-screen project + brain + adapter summary above the menu prompt."""
39+
doc = state_mod.load(target_root) or {}
40+
adapters = doc.get("adapters", {}) or {}
41+
42+
print()
43+
print(f"{PURPLE}{B}╭─ agentic-stack — manage{R}")
44+
print(f"{BAR} project: {WHITE}{target_root}{R}")
45+
print(f"{BAR} brain: .agent/ {MUTED}({_brain_summary(target_root)}){R}")
46+
print(f"{BAR} version: agentic-stack {doc.get('agentic_stack_version', __version__)}")
47+
print(f"{BAR}")
48+
if not adapters:
49+
print(f"{BAR} {MUTED}no adapters installed yet{R}")
50+
else:
51+
print(f"{BAR} installed adapters ({len(adapters)}):")
52+
# Quick non-mutating audit of each adapter so we can show colour
53+
# status alongside name.
54+
for name in sorted(adapters):
55+
entry = adapters[name]
56+
status, _ = doctor_mod._audit_adapter(target_root, name, entry)
57+
glyph_color = {"green": GREEN, "yellow": ORANGE, "red": MUTED}.get(status, MUTED)
58+
glyph = {"green": "✓", "yellow": "⚠", "red": "✗"}.get(status, "?")
59+
print(f"{BAR} {glyph_color}{glyph}{R} {WHITE}{name:18s}{R} {glyph_color}{status}{R}")
60+
print(f"{BAR}")
61+
62+
63+
def _brain_summary(target_root: Path) -> str:
64+
return status_mod._brain_summary(target_root)
65+
66+
67+
# ---- menu actions ----------------------------------------------------
68+
69+
def _action_add(target_root: Path, stack_root: Path) -> None:
70+
"""Multi-select adapters NOT already installed; install each."""
71+
doc = state_mod.load(target_root) or {}
72+
installed = set(doc.get("adapters") or {})
73+
available = sorted(
74+
n for n, _ in schema_mod.discover_all(stack_root)
75+
if n not in installed
76+
)
77+
if not available:
78+
print(f" {MUTED}all available adapters are already installed.{R}")
79+
return
80+
chosen = widgets.ask_multiselect(
81+
"select adapters to add (q to cancel)",
82+
available,
83+
)
84+
if not chosen:
85+
print(f" {MUTED}no adapters selected; nothing changed.{R}")
86+
return
87+
for name in chosen:
88+
manifest_path = stack_root / "adapters" / name / "adapter.json"
89+
manifest = schema_mod.validate(manifest_path)
90+
install_mod.install(
91+
manifest=manifest,
92+
target_root=target_root,
93+
adapter_dir=stack_root / "adapters" / name,
94+
stack_root=stack_root,
95+
)
96+
97+
98+
def _action_remove(target_root: Path) -> None:
99+
"""Multi-select adapters from installed; remove each (with per-adapter confirm)."""
100+
doc = state_mod.load(target_root) or {}
101+
installed = sorted((doc.get("adapters") or {}).keys())
102+
if not installed:
103+
print(f" {MUTED}nothing to remove — no adapters installed.{R}")
104+
return
105+
chosen = widgets.ask_multiselect(
106+
"select adapters to remove (q to cancel)",
107+
installed,
108+
)
109+
if not chosen:
110+
print(f" {MUTED}no adapters selected; nothing changed.{R}")
111+
return
112+
for name in chosen:
113+
# remove() prompts for its own per-adapter [y/N] confirm with the
114+
# destruction file list. That's the right place to ask, not the
115+
# menu — keeps the destructive prompt next to the destructive op.
116+
remove_mod.remove(target_root, name, yes=False)
117+
118+
119+
def _action_doctor(target_root: Path) -> None:
120+
"""Run audit. Same exit-code semantics as `./install.sh doctor`."""
121+
rc = doctor_mod.audit(target_root)
122+
if rc != 0:
123+
print(f" {ORANGE}doctor exited non-zero — see above.{R}")
124+
125+
126+
def _action_status(target_root: Path) -> None:
127+
"""Print the full status view (header pane only shows summary)."""
128+
status_mod.show(target_root)
129+
130+
131+
def _action_reconfigure(target_root: Path, stack_root: Path) -> None:
132+
"""Re-run onboard.py PREFERENCES.md flow."""
133+
onboard = stack_root / "onboard.py"
134+
if not onboard.is_file():
135+
print(f" {MUTED}onboard.py not found at {onboard}{R}")
136+
return
137+
import subprocess
138+
subprocess.run(
139+
[sys.executable, str(onboard), str(target_root), "--reconfigure"],
140+
check=False,
141+
)
142+
143+
144+
# ---- main menu loop --------------------------------------------------
145+
146+
# Ordered: user-facing labels (left) → internal action tag (right).
147+
_MENU = [
148+
("Add an adapter", "add"),
149+
("Remove an adapter", "remove"),
150+
("Run doctor (audit)", "doctor"),
151+
("Show status", "status"),
152+
("Reconfigure preferences", "reconfigure"),
153+
("Exit", "exit"),
154+
]
155+
156+
157+
def _sigint_handler_factory():
158+
"""SIGINT handler with two-Ctrl-C-to-exit semantics.
159+
160+
First Ctrl-C inside the menu: print a hint, return to menu (handled
161+
by the loop catching KeyboardInterrupt). Two consecutive Ctrl-Cs
162+
within ~1 second: actually exit. Reset the counter on every
163+
successful menu iteration.
164+
"""
165+
state = {"last": 0.0}
166+
def _handler(signum, frame):
167+
import time
168+
now = time.time()
169+
if now - state["last"] < 1.0:
170+
print()
171+
print(f" {MUTED}two ctrl-c — exiting.{R}")
172+
sys.exit(130)
173+
state["last"] = now
174+
# Re-raise as KeyboardInterrupt so the loop body can catch it.
175+
raise KeyboardInterrupt
176+
return _handler
177+
178+
179+
def run(target_root: Path | str, stack_root: Path | str) -> int:
180+
"""Enter the menu loop. Returns exit code on quit."""
181+
target_root = Path(target_root).resolve()
182+
stack_root = Path(stack_root)
183+
184+
# Install our SIGINT handler.
185+
signal.signal(signal.SIGINT, _sigint_handler_factory())
186+
187+
while True:
188+
try:
189+
_render_header(target_root)
190+
choice_label = widgets.ask_select(
191+
"what do you want to do?",
192+
[label for label, _ in _MENU],
193+
)
194+
tag = next(t for label, t in _MENU if label == choice_label)
195+
except KeyboardInterrupt:
196+
print()
197+
print(f" {MUTED}ctrl-c again to exit, or pick an action.{R}")
198+
continue
199+
200+
if tag == "exit":
201+
print(f" {MUTED}bye.{R}")
202+
return 0
203+
204+
try:
205+
if tag == "add": _action_add(target_root, stack_root)
206+
elif tag == "remove": _action_remove(target_root)
207+
elif tag == "doctor": _action_doctor(target_root)
208+
elif tag == "status": _action_status(target_root)
209+
elif tag == "reconfigure": _action_reconfigure(target_root, stack_root)
210+
except KeyboardInterrupt:
211+
print()
212+
print(f" {MUTED}action cancelled.{R}")
213+
except Exception as e: # pragma: no cover — last-resort safety
214+
print(f" {ORANGE}error: {type(e).__name__}: {e}{R}")
215+
# Loop back to header + menu.

onboard_widgets.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,76 @@ def _render():
6767
return choices[sel]
6868

6969

70+
def ask_multiselect(label, choices, defaults=None, hint_extra=""):
71+
"""Arrow-key multi-select with space-toggle. Returns list of chosen strings.
72+
73+
`choices`: list of strings.
74+
`defaults`: list of indices to start checked (default: none).
75+
`hint_extra`: extra hint string appended to the navigation help.
76+
77+
Same posture as ask_select: clack ◇ label, BAR rail, ▮ summary.
78+
Glyphs: ◉ checked / ○ unchecked, ▸ cursor.
79+
Keys: ↑↓ navigate · space toggle · enter confirm · q cancel (returns []).
80+
81+
NOTE: uses `q` (not ESC) to cancel because the existing POSIX get_key()
82+
blocks on lone ESC waiting for an arrow-key sequence's second byte.
83+
`q` is universally available, no platform-specific terminal dance.
84+
"""
85+
sel = set(defaults or [])
86+
cur = 0
87+
88+
def _render():
89+
for i, c in enumerate(choices):
90+
box = f"{GREEN}{R}" if i in sel else f"{MUTED}{R}"
91+
arrow = f"{BLUE}{R}" if i == cur else " "
92+
label_color = f"{WHITE}{B}" if i == cur else MUTED
93+
print(f"{BAR} {arrow} {box} {label_color}{c}{R}")
94+
print(f"{MUTED}{R}")
95+
96+
hint = f" {MUTED}↑↓ navigate · space toggle · enter confirm · q cancel{hint_extra}{R}"
97+
print(f"{PURPLE}{R} {B}{WHITE}{label}{R}{hint}")
98+
_render()
99+
100+
sys.stdout.write(HIDE)
101+
sys.stdout.flush()
102+
n = len(choices)
103+
cancelled = False
104+
try:
105+
while True:
106+
key = get_key()
107+
if key == "UP": cur = (cur - 1) % n
108+
elif key == "DOWN": cur = (cur + 1) % n
109+
elif key == " ": # literal space toggles current row
110+
if cur in sel: sel.remove(cur)
111+
else: sel.add(cur)
112+
elif key == "ENTER": break
113+
elif key == "q" or key == "Q":
114+
cancelled = True
115+
break
116+
else:
117+
continue
118+
for _ in range(n + 1):
119+
sys.stdout.write(UP + CLR)
120+
sys.stdout.flush()
121+
_render()
122+
finally:
123+
sys.stdout.write(SHOW)
124+
sys.stdout.flush()
125+
126+
for _ in range(n + 2):
127+
sys.stdout.write(UP + CLR)
128+
sys.stdout.flush()
129+
130+
if cancelled:
131+
step_done(label, "(cancelled)")
132+
return []
133+
134+
chosen = [choices[i] for i in sorted(sel)]
135+
summary = ", ".join(chosen) if chosen else "(none)"
136+
step_done(label, summary)
137+
return chosen
138+
139+
70140
def ask_confirm(label, default=True):
71141
"""Y / n confirm. Returns bool."""
72142
hint = f"{'Y/n' if default else 'y/N'}"

0 commit comments

Comments
 (0)