fix: use TUI modal for slash confirmations

This commit is contained in:
zhengyuna 2026-05-11 12:14:41 +08:00 committed by Teknium
parent e155f2aca9
commit 054f568578
3 changed files with 391 additions and 67 deletions

371
cli.py
View file

@ -2558,6 +2558,8 @@ class HermesCLI:
self._approval_state = None
self._approval_deadline = 0
self._approval_lock = threading.Lock()
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._model_picker_state = None
self._secret_state = None
self._secret_deadline = 0
@ -3715,7 +3717,7 @@ class HermesCLI:
if self._command_running:
_cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
return False
if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:
if self._sudo_state or self._secret_state or self._approval_state or self._slash_confirm_state or self._clarify_state:
_cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
return False
target_buffer = buffer or getattr(app, "current_buffer", None)
@ -6059,6 +6061,194 @@ class HermesCLI:
_ask()
return result[0]
def _prompt_text_input_modal(
self,
*,
title: str,
detail: str,
choices: list[tuple[str, str, str]],
timeout: float = 120,
) -> str | None:
"""Prompt through the prompt_toolkit composer instead of raw input().
This is for CLI slash-command confirmations. The old raw input() path
fought prompt_toolkit's active stdin ownership: in some terminals the
prompt appeared above the TUI, choices were redrawn later, and Enter
could be interpreted as EOF/exit. A first-class modal state keeps the
choices visible and lets the normal Enter key binding submit the typed
or highlighted choice.
"""
import time as _time
if not choices:
return None
# If prompt_toolkit is not running (unit tests / non-interactive calls),
# keep the simple stdin fallback.
if not getattr(self, "_app", None):
return self._prompt_text_input("Choice [1/2/3]: ")
response_queue = queue.Queue()
self._capture_modal_input_snapshot()
self._slash_confirm_state = {
"title": title,
"detail": detail,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
self._slash_confirm_deadline = _time.monotonic() + timeout
self._invalidate()
_last_countdown_refresh = _time.monotonic()
try:
while True:
try:
result = response_queue.get(timeout=1)
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
return result
except queue.Empty:
remaining = self._slash_confirm_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 5.0:
_last_countdown_refresh = now
self._invalidate()
finally:
if self._slash_confirm_state is not None:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
return None
def _submit_slash_confirm_response(self, value: str | None) -> None:
state = self._slash_confirm_state
if not state:
return
state["response_queue"].put(value)
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._invalidate()
def _normalize_slash_confirm_choice(
self,
raw: str | None,
choices: list[tuple[str, str, str]],
) -> str | None:
if raw is None:
return None
choice_raw = raw.strip().lower()
if not choice_raw:
return None
aliases = {
"1": "once",
"once": "once",
"approve": "once",
"yes": "once",
"y": "once",
"ok": "once",
"2": "always",
"always": "always",
"remember": "always",
"3": "cancel",
"cancel": "cancel",
"nevermind": "cancel",
"no": "cancel",
"n": "cancel",
}
allowed = {choice[0] for choice in choices}
normalized = aliases.get(choice_raw)
if normalized in allowed:
return normalized
if choice_raw in allowed:
return choice_raw
return None
def _get_slash_confirm_display_fragments(self):
"""Render the /new-/clear-style confirmation panel."""
state = self._slash_confirm_state
if not state:
return []
title = state.get("title") or "Confirm action"
detail = state.get("detail") or ""
choices = state.get("choices") or []
selected = state.get("selected", 0)
def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int:
term_cols = shutil.get_terminal_size((100, 20)).columns
longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4])
inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
return inner + 2
def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
wrapped = textwrap.wrap(
text,
width=max(8, width),
replace_whitespace=False,
drop_whitespace=False,
subsequent_indent=subsequent_indent,
)
return wrapped or [""]
def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
inner_width = max(0, box_width - 2)
lines.append((border_style, ""))
lines.append((content_style, text.ljust(inner_width)))
lines.append((border_style, "\n"))
def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
lines.append((border_style, "" + (" " * box_width) + "\n"))
preview_lines = []
for line in detail.splitlines():
preview_lines.extend(_wrap_panel_text(line, 72))
for idx, (_value, label, desc) in enumerate(choices):
marker = "" if idx == selected else " "
preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label}{desc}", 72, subsequent_indent=" "))
preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.")
box_width = _panel_box_width(title, preview_lines)
inner_text_width = max(8, box_width - 2)
detail_wrapped = []
for line in detail.splitlines():
detail_wrapped.extend(_wrap_panel_text(line, inner_text_width))
choice_wrapped: list[tuple[int, str]] = []
for idx, (_value, label, desc) in enumerate(choices):
marker = "" if idx == selected else " "
for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label}{desc}", inner_text_width, subsequent_indent=" "):
choice_wrapped.append((idx, wrapped))
term_rows = shutil.get_terminal_size((100, 24)).lines
reserved_below = 6
chrome_full = 6
available = max(0, term_rows - reserved_below)
max_detail_rows = max(1, available - chrome_full - len(choice_wrapped))
max_detail_rows = min(max_detail_rows, 8)
if len(detail_wrapped) > max_detail_rows:
keep = max(1, max_detail_rows - 1)
detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"]
lines = []
lines.append(('class:approval-border', '' + ('' * box_width) + '\n'))
_append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
for wrapped in detail_wrapped:
_append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
for idx, wrapped in choice_wrapped:
style = 'class:approval-selected' if idx == selected else 'class:approval-choice'
_append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
_append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width)
lines.append(('class:approval-border', '' + ('' * box_width) + '\n'))
return lines
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
@ -8577,30 +8767,24 @@ class HermesCLI:
if not confirm_required:
return "once"
# Render warning + prompt — single-line composer prompt, mirrors
# ``_confirm_and_reload_mcp``.
print()
print(f"⚠️ /{command} — destroys conversation state")
print()
for line in detail.splitlines():
print(f" {line}")
print()
print(" [1] Approve Once — proceed this time only")
print(" [2] Always Approve — proceed and silence this prompt permanently")
print(" [3] Cancel — keep current conversation")
print()
raw = self._prompt_text_input("Choice [1/2/3]: ")
# Render a prompt_toolkit-native confirmation panel. This keeps option
# labels visible above the composer and avoids raw input()/EOF races with
# the running TUI.
choices = [
("once", "Approve Once", "proceed this time only"),
("always", "Always Approve", "proceed and silence this prompt permanently"),
("cancel", "Cancel", "keep current conversation"),
]
raw = self._prompt_text_input_modal(
title=f"⚠️ /{command} — destroys conversation state",
detail=detail,
choices=choices,
)
if raw is None:
print(f"🟡 /{command} cancelled (no input).")
return None
choice_raw = raw.strip().lower()
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
choice = "once"
elif choice_raw in ("2", "always", "remember"):
choice = "always"
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
choice = "cancel"
else:
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice is None:
print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.")
return None
@ -8645,32 +8829,28 @@ class HermesCLI:
self._reload_mcp()
return
# Render warning + prompt. Use a single-line prompt so the user
# sees the warning as output and types a response into the composer.
print()
print("⚠️ /reload-mcp — Prompt cache invalidation warning")
print()
print(" Reloading MCP servers rebuilds the tool set for this session and")
print(" invalidates the provider prompt cache. The next message will")
print(" re-send full input tokens (can be expensive on long-context or")
print(" high-reasoning models).")
print()
print(" [1] Approve Once — reload now")
print(" [2] Always Approve — reload now and silence this prompt permanently")
print(" [3] Cancel — leave MCP tools unchanged")
print()
raw = self._prompt_text_input("Choice [1/2/3]: ")
# Render warning + prompt. Use the same prompt_toolkit-native composer
# modal as destructive slash confirmations so choices stay visible.
choices = [
("once", "Approve Once", "reload now"),
("always", "Always Approve", "reload now and silence this prompt permanently"),
("cancel", "Cancel", "leave MCP tools unchanged"),
]
raw = self._prompt_text_input_modal(
title="⚠️ /reload-mcp — Prompt cache invalidation warning",
detail=(
"Reloading MCP servers rebuilds the tool set for this session and\n"
"invalidates the provider prompt cache. The next message will\n"
"re-send full input tokens (can be expensive on long-context or\n"
"high-reasoning models)."
),
choices=choices,
)
if raw is None:
print("🟡 /reload-mcp cancelled (no input).")
return
choice_raw = raw.strip().lower()
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
choice = "once"
elif choice_raw in ("2", "always", "remember"):
choice = "always"
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
choice = "cancel"
else:
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice is None:
print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
return
@ -10594,6 +10774,8 @@ class HermesCLI:
return _state_fragment("class:sudo-prompt", "🔑")
if self._approval_state:
return _state_fragment("class:prompt-working", "")
if self._slash_confirm_state:
return _state_fragment("class:prompt-working", "")
if self._clarify_freetext:
return _state_fragment("class:clarify-selected", "")
if self._clarify_state:
@ -10660,6 +10842,7 @@ class HermesCLI:
sudo_widget,
secret_widget,
approval_widget,
slash_confirm_widget,
clarify_widget,
model_picker_widget=None,
spinner_widget=None,
@ -10684,6 +10867,7 @@ class HermesCLI:
sudo_widget,
secret_widget,
approval_widget,
slash_confirm_widget,
clarify_widget,
model_picker_widget,
spinner_widget,
@ -10846,6 +11030,13 @@ class HermesCLI:
self._approval_deadline = 0
self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix)
# Destructive slash-command confirmation state (/new, /clear, /undo).
# These prompts are answered through the prompt_toolkit composer, not
# raw input(), so the option labels stay visible and Enter does not EOF
# the whole app.
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
# Slash command loading state
self._command_running = False
self._command_status = ""
@ -10937,6 +11128,20 @@ class HermesCLI:
event.app.invalidate()
return
# --- Slash-command confirmation: submit typed or highlighted choice ---
if self._slash_confirm_state:
text = event.app.current_buffer.text.strip()
choices = self._slash_confirm_state.get("choices") or []
choice = self._normalize_slash_confirm_choice(text, choices) if text else None
if choice is None:
selected = self._slash_confirm_state.get("selected", 0)
if 0 <= selected < len(choices):
choice = choices[selected][0]
self._submit_slash_confirm_response(choice or "cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
# --- /model picker modal ---
if self._model_picker_state:
try:
@ -11197,6 +11402,20 @@ class HermesCLI:
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
event.app.invalidate()
# --- Slash-command confirmation: arrow-key navigation ---
@kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state)))
def slash_confirm_up(event):
if self._slash_confirm_state:
self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1)
event.app.invalidate()
@kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state)))
def slash_confirm_down(event):
if self._slash_confirm_state:
max_idx = len(self._slash_confirm_state.get("choices") or []) - 1
self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1)
event.app.invalidate()
# --- /model picker: arrow-key navigation ---
@kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
def model_picker_up(event):
@ -11237,12 +11456,26 @@ class HermesCLI:
_idx = 9 if _num == 0 else _num - 1
kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx))
# Number keys for quick slash-confirm selection (1-9, 0 for 10th item)
def _make_slash_confirm_number_handler(idx):
def handler(event):
if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []):
choice = self._slash_confirm_state["choices"][idx][0]
self._submit_slash_confirm_response(choice)
event.app.current_buffer.reset()
event.app.invalidate()
return handler
for _num in range(10):
_idx = 9 if _num == 0 else _num - 1
kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx))
# --- History navigation: up/down browse history in normal input mode ---
# The TextArea is multiline, so by default up/down only move the cursor.
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
# history browsing when on the first/last line (or single-line input).
_normal_input = Condition(
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
)
@kb.add('up', filter=_normal_input)
@ -11318,6 +11551,13 @@ class HermesCLI:
event.app.invalidate()
return
# Cancel slash confirmation prompt
if self._slash_confirm_state:
self._submit_slash_confirm_response("cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
# Cancel /model picker
if self._model_picker_state:
self._close_model_picker()
@ -11412,6 +11652,13 @@ class HermesCLI:
event.app.invalidate()
return
# Cancel slash confirmation prompt
if self._slash_confirm_state:
self._submit_slash_confirm_response("cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
# Cancel /model picker
if self._model_picker_state:
self._close_model_picker()
@ -11460,7 +11707,7 @@ class HermesCLI:
event.app.exit()
_modal_prompt_active = Condition(
lambda: bool(self._secret_state or self._sudo_state)
lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)
)
@kb.add('escape', filter=_modal_prompt_active, eager=True)
@ -11476,6 +11723,11 @@ class HermesCLI:
self._sudo_state = None
event.app.invalidate()
return
if self._slash_confirm_state:
self._submit_slash_confirm_response("cancel")
event.app.current_buffer.reset()
event.app.invalidate()
return
@kb.add('c-z')
def handle_ctrl_z(event):
@ -11558,7 +11810,7 @@ class HermesCLI:
# Guard: don't START recording during agent run or interactive prompts
if cli_ref._agent_running:
return
if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:
if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:
return
# Guard: don't start while a previous stop/transcribe cycle is
# still running — recorder.stop() holds AudioRecorder._lock and
@ -11845,6 +12097,8 @@ class HermesCLI:
return "type secret (hidden), Enter to submit · ESC to skip"
if cli_ref._approval_state:
return ""
if cli_ref._slash_confirm_state:
return "type 1/2/3, or use ↑/↓ then Enter"
if cli_ref._clarify_freetext:
return "type your answer here and press Enter"
if cli_ref._clarify_state:
@ -11887,6 +12141,13 @@ class HermesCLI:
('class:clarify-countdown', f' ({remaining}s)'),
]
if cli_ref._slash_confirm_state:
remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))
return [
('class:hint', ' type 1/2/3, or ↑/↓ to select, Enter to confirm'),
('class:clarify-countdown', f' ({remaining}s)'),
]
if cli_ref._clarify_state:
remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))
countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else ''
@ -11909,7 +12170,7 @@ class HermesCLI:
return []
def get_hint_height():
if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:
if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running:
return 1
# Keep a spacer while the agent runs on roomy terminals, but reclaim
# the row on narrow/mobile screens where every line matters.
@ -12213,6 +12474,17 @@ class HermesCLI:
filter=Condition(lambda: cli_ref._approval_state is not None),
)
def _get_slash_confirm_display():
return cli_ref._get_slash_confirm_display_fragments()
slash_confirm_widget = ConditionalContainer(
Window(
FormattedTextControl(_get_slash_confirm_display),
wrap_lines=True,
),
filter=Condition(lambda: cli_ref._slash_confirm_state is not None),
)
# --- /model picker: display widget ---
def _get_model_picker_display():
state = cli_ref._model_picker_state
@ -12358,6 +12630,7 @@ class HermesCLI:
sudo_widget=sudo_widget,
secret_widget=secret_widget,
approval_widget=approval_widget,
slash_confirm_widget=slash_confirm_widget,
clarify_widget=clarify_widget,
model_picker_widget=model_picker_widget,
spinner_widget=spinner_widget,

View file

@ -6,6 +6,7 @@ don't have to construct a full HermesCLI (which requires extensive setup).
from __future__ import annotations
import queue
from types import SimpleNamespace
from unittest.mock import patch
@ -17,10 +18,17 @@ def _bound(fn, instance):
def _make_self(prompt_response):
"""Build a minimal stand-in 'self' for _confirm_destructive_slash."""
return SimpleNamespace(
from cli import HermesCLI
self_ = SimpleNamespace(
_app=None,
_prompt_text_input=lambda _prompt: prompt_response,
_prompt_text_input_modal=lambda **_kw: prompt_response,
)
self_._normalize_slash_confirm_choice = _bound(
HermesCLI._normalize_slash_confirm_choice, self_,
)
return self_
def test_gate_off_returns_once_without_prompting():
@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always():
self_ = _make_self(prompt_response="2")
saves = []
def _fake_save(key, value):
saves.append((key, value))
return True
@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing():
# treated as on despite the config error. If the gate had been off
# this would have returned 'once' without consulting the prompt.
assert result is None
def test_slash_confirm_modal_number_selection_submits_without_raw_input():
"""Pressing 2 in the TUI modal should resolve to Always Approve directly."""
from cli import HermesCLI
q = queue.Queue()
self_ = SimpleNamespace(
_slash_confirm_state={
"choices": [
("once", "Approve Once", "proceed once"),
("always", "Always Approve", "persist opt-out"),
("cancel", "Cancel", "abort"),
],
"selected": 0,
"response_queue": q,
},
_slash_confirm_deadline=123,
_invalidate=lambda: None,
)
_bound(HermesCLI._submit_slash_confirm_response, self_)("always")
assert q.get_nowait() == "always"
assert self_._slash_confirm_state is None
assert self_._slash_confirm_deadline == 0
def test_slash_confirm_display_fragments_include_choice_mapping():
"""The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
from cli import HermesCLI
self_ = SimpleNamespace(
_slash_confirm_state={
"title": "⚠️ /new — destroys conversation state",
"detail": "This starts a fresh session.",
"choices": [
("once", "Approve Once", "proceed once"),
("always", "Always Approve", "persist opt-out"),
("cancel", "Cancel", "abort"),
],
"selected": 1,
},
)
fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
rendered = "".join(fragment for _style, fragment in fragments)
assert "[1] Approve Once" in rendered
assert "[2] Always Approve" in rendered
assert "[3] Cancel" in rendered
assert "Type 1/2/3" in rendered

View file

@ -1,15 +1,9 @@
"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched
from the ``process_loop`` daemon thread. ``prompt_toolkit.run_in_terminal``
returns a coroutine that only the main-thread event loop can drive; calling it
from a daemon thread orphans the coroutine, ``_ask`` never runs, and user
keystrokes leak into the composer instead of the confirmation prompt
(see issue #23185).
The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to
a direct ``input()`` call so the prompt actually renders and consumes
keystrokes.
Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
The normal slash confirmations now use a prompt_toolkit-native modal, but
``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
cases.
"""
import threading
@ -17,7 +11,7 @@ from unittest.mock import MagicMock, patch
def _make_cli():
"""Minimal HermesCLI shell exposing ``_prompt_text_input``."""
"""Minimal HermesCLI shell exposing prompt fallback helpers."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
@ -33,7 +27,7 @@ class TestPromptTextInputThreadSafety:
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
patch("builtins.input", return_value="2"):
result = cli._prompt_text_input("Choice: ")
cli._prompt_text_input("Choice: ")
# run_in_terminal was invoked; the _ask closure passed to it would
# call input() when driven by the event loop. We assert dispatch path,
@ -43,10 +37,8 @@ class TestPromptTextInputThreadSafety:
def test_background_thread_falls_back_to_direct_input(self):
"""On a daemon thread, skip run_in_terminal and call input() directly.
This is the bug from issue #23185: process_loop dispatches slash
commands on a daemon thread, so run_in_terminal's coroutine is
orphaned. The fallback must drive input() itself so user keystrokes
don't leak into the agent buffer.
This preserves the fallback for any prompt that still runs off the main
UI thread: run_in_terminal's coroutine would otherwise be orphaned.
"""
cli = _make_cli()
captured = {}