monkeybot-cli 0.11.1__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/.gitignore +3 -0
  2. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/PKG-INFO +1 -1
  3. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/pyproject.toml +1 -1
  4. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/skills/monkeybot/SKILL.md +8 -4
  5. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_session.py +101 -4
  6. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_tui.py +91 -19
  7. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_tui_widgets.py +152 -51
  8. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/doctor.py +13 -1
  9. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +1 -1
  10. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_session.py +24 -0
  11. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_tui.py +20 -3
  12. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_doctor.py +6 -2
  13. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/uv.lock +3 -2
  14. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/skills/monkeybot/references/config-sections.md +0 -0
  15. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/__init__.py +0 -0
  16. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_file_index.py +0 -0
  17. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_local_shell.py +0 -0
  18. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_renderer.py +0 -0
  19. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_status_bar.py +0 -0
  20. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_theme.py +0 -0
  21. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/chat_tool_display.py +0 -0
  22. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/__init__.py +0 -0
  23. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/chat.py +0 -0
  24. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/loop.py +0 -0
  25. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/mcp_login.py +0 -0
  26. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/new.py +0 -0
  27. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/refresh.py +0 -0
  28. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/run_cmd.py +0 -0
  29. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/talk.py +0 -0
  30. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/trace.py +0 -0
  31. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/commands/validate.py +0 -0
  32. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/compat.py +0 -0
  33. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/config_resolve.py +0 -0
  34. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/exit_commands.py +0 -0
  35. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/extras_catalog.py +0 -0
  36. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/gateway_health.py +0 -0
  37. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/main.py +0 -0
  38. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/opensandbox_lifecycle.py +0 -0
  39. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/output.py +0 -0
  40. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/overrides.txt +0 -0
  41. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/process_tree.py +0 -0
  42. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/providers.py +0 -0
  43. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/__init__.py +0 -0
  44. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/audio_io.py +0 -0
  45. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/client.py +0 -0
  46. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
  47. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/push_to_talk.py +0 -0
  48. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/session.py +0 -0
  49. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/session_controller.py +0 -0
  50. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/talk_ui.py +0 -0
  51. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
  52. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/runtime_python.py +0 -0
  53. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold.py +0 -0
  54. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
  55. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
  56. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
  57. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -0
  58. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +0 -0
  59. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
  60. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/env.example +0 -0
  61. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/goal/SKILL.md +0 -0
  62. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
  63. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
  64. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
  65. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +0 -0
  66. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
  67. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
  68. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
  69. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/AGENT.md +0 -0
  70. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/command_allowlist.yaml +0 -0
  71. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/monkeybot.overrides.yaml +0 -0
  72. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/permissions.yaml +0 -0
  73. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/session_controller.py +0 -0
  74. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/src/monkeybot_cli/terminal_markdown.py +0 -0
  75. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_continue.py +0 -0
  76. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_e2e.py +0 -0
  77. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_errors.py +0 -0
  78. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_file_index.py +0 -0
  79. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_grounding.py +0 -0
  80. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_local_shell.py +0 -0
  81. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_renderer_parity.py +0 -0
  82. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_status_bar.py +0 -0
  83. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_theme.py +0 -0
  84. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_chat_tool_display.py +0 -0
  85. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_cli.py +0 -0
  86. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_config_resolve.py +0 -0
  87. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_extras_catalog.py +0 -0
  88. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_gateway_health.py +0 -0
  89. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_opensandbox_lifecycle.py +0 -0
  90. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_realtime_session_controller.py +0 -0
  91. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_run_cmd.py +0 -0
  92. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_runtime_python.py +0 -0
  93. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_scaffold.py +0 -0
  94. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_terminal_markdown.py +0 -0
  95. {monkeybot_cli-0.11.1 → monkeybot_cli-0.13.0}/tests/test_trace_cmd.py +0 -0
@@ -11,6 +11,9 @@ internal/
11
11
  # Local scaffolded harness config (defaults ship in monkeybot-cli scaffold_defaults/)
12
12
  /monkeybot_config/
13
13
 
14
+ # Local agent for manual CLI testing. Recreate with `monkeybot new --dest ./cli-agent`.
15
+ /cli-agent/
16
+
14
17
  # Live eval run artifacts. Baselines under regression/t4/baselines/ stay versioned.
15
18
  /regression/t4/runs/
16
19
  # Live-eval agent project local state. Live secrets come from the repo-root .env; a stray key
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: monkeybot-cli
3
- Version: 0.11.1
3
+ Version: 0.13.0
4
4
  Summary: CLI to create, configure, validate, and chat with monkeybot agents.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "monkeybot-cli"
7
- version = "0.11.1"
7
+ version = "0.13.0"
8
8
  description = "CLI to create, configure, validate, and chat with monkeybot agents."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -31,11 +31,15 @@ Tell the user what you're installing and why. On macOS/Linux you can run these y
31
31
  ### 2. Install the global CLI
32
32
 
33
33
  ```bash
34
- uv tool install monkeybot-cli
34
+ # browser-harness pins Pillow 12. Core 3.12 needs Pillow 13, and
35
+ # `uv tool install` ignores the package override unless this file is passed.
36
+ # Without it the resolver stays on core 3.10.0.
37
+ printf 'pillow>=13\n' > /tmp/monkeybot-overrides.txt
38
+ uv tool install --overrides /tmp/monkeybot-overrides.txt monkeybot-cli
35
39
  monkeybot --help
36
40
  ```
37
41
 
38
- Upgrade later with `uv tool upgrade monkeybot-cli`.
42
+ Upgrade later with `uv tool install --reinstall --overrides /tmp/monkeybot-overrides.txt monkeybot-cli`.
39
43
 
40
44
  **Do not** tell day-1 users to clone `human-plus-machine/monkeybot` unless they are contributing to the harness itself (see Contributing below).
41
45
 
@@ -47,7 +51,7 @@ If the user arrived via skills.sh:
47
51
  npx skills add human-plus-machine/monkeybot --skill monkeybot
48
52
  ```
49
53
 
50
- …they have **this skill only**. Walk them through Tier 0: install `uv` → `uv tool install monkeybot-cli` → verify `monkeybot --help`. Then continue to Tier 1.
54
+ …they have **this skill only**. Walk them through Tier 0: install `uv` → `uv tool install --overrides /tmp/monkeybot-overrides.txt monkeybot-cli` (write `pillow>=13` into that file first) → verify `monkeybot --help`. Then continue to Tier 1.
51
55
 
52
56
  ## How config works (tell the user this first)
53
57
 
@@ -115,7 +119,7 @@ uv sync
115
119
  | `ollama-local` | None required — `OLLAMA_BASE_URL` (default `http://localhost:11434`). Requests send `keep_alive` 24h so prefix KV cache survives idle. | `monkeybot[ollama]` |
116
120
  | `ollama` | Legacy auto-route (key + blank URL = cloud; explicit URL wins) | `monkeybot[ollama]` |
117
121
 
118
- **Agent-first dependencies.** The CLI is thin — it does **not** install provider/storage extras globally. `monkeybot new` scaffolds a `pyproject.toml` with the selected provider (and any `--with` extras). Run plain `uv sync` in the agent directory. `monkeybot run` / `chat` spawn the gateway from that project's interpreter (`.venv/bin/python`, else `uv run python`), and `doctor` checks extras in that same interpreter. For a config-only tree (just `monkeybot_config/`, no `pyproject.toml`) the gateway uses the CLI's interpreter when it already has MonkeyBot 3.x (and MemPalace if memory is on). If memory is enabled and that interpreter cannot import MemPalace, `run` / `chat` provision a cached CLI-managed venv under `~/.cache/monkeybot/runtimes/` holding `monkeybot[memory]` pinned to the running core (never rewrites a `pyproject.toml`). `doctor` reuses that cache when it is already present. Otherwise extras must be installed in the CLI env (`uv tool install --with 'monkeybot[<extra>]' monkeybot-cli`).
122
+ **Agent-first dependencies.** The CLI is thin — it does **not** install provider/storage extras globally. `monkeybot new` scaffolds a `pyproject.toml` with the selected provider (and any `--with` extras). Run plain `uv sync` in the agent directory. `monkeybot run` / `chat` spawn the gateway from that project's interpreter (`.venv/bin/python`, else `uv run python`), and `doctor` checks extras in that same interpreter. For a config-only tree (just `monkeybot_config/`, no `pyproject.toml`) the gateway uses the CLI's interpreter when it already has MonkeyBot 3.x (and MemPalace if memory is on). If memory is enabled and that interpreter cannot import MemPalace, `run` / `chat` provision a cached CLI-managed venv under `~/.cache/monkeybot/runtimes/` holding `monkeybot[memory]` pinned to the running core (never rewrites a `pyproject.toml`). `doctor` reuses that cache when it is already present. Otherwise extras must be installed in the CLI env (`uv tool install --reinstall --overrides /tmp/monkeybot-overrides.txt --with 'monkeybot[<extra>]' monkeybot-cli`, with `pillow>=13` in that file). A bare `uv tool install` resolves core 3.10.0.
119
123
 
120
124
  `doctor` is the source of truth for credentials and extras — when in doubt, run it and read the `remediation` field (add `monkeybot[<extra>]` to agent deps + `uv sync`).
121
125
 
@@ -241,6 +241,8 @@ class ChatSessionController:
241
241
  self._reconnecting = False
242
242
  self._last_event_id: int | None = None
243
243
  self._abandoned: deque[str] = deque(maxlen=64)
244
+ self._deferred: list[Any] = []
245
+ self._idle_task: asyncio.Task[None] | None = None
244
246
  self._turn_abort = asyncio.Event()
245
247
  self._active_request_id: str | None = None
246
248
  self._hitl_future: asyncio.Future[HitlAnswer] | None = None
@@ -292,6 +294,7 @@ class ChatSessionController:
292
294
  self._reconnecting = False
293
295
  self._last_event_id = None
294
296
  self._stream_task = asyncio.create_task(self._consume_stream())
297
+ self._start_idle_watch()
295
298
  self._emit("session_ready", session_id=self.session_id)
296
299
  self._emit("connection_state", state="connected")
297
300
  await self._emit_transcript_backfill()
@@ -504,6 +507,14 @@ class ChatSessionController:
504
507
  return
505
508
 
506
509
  request_id = str(uuid.uuid4())
510
+ if self._active_request_id is not None:
511
+ self._emit("session_busy")
512
+ return
513
+ await self._stop_idle_watch()
514
+ if self._active_request_id is not None:
515
+ self._emit("session_busy")
516
+ self._start_idle_watch()
517
+ return
507
518
  self._active_request_id = request_id
508
519
  self._last_cancel_ok = None
509
520
  self._turn_abort.clear()
@@ -513,15 +524,23 @@ class ChatSessionController:
513
524
  json={"request_id": request_id, "message": message},
514
525
  )
515
526
  if reply.status_code == 409:
527
+ self._active_request_id = None
516
528
  self._emit("session_busy")
529
+ self._start_idle_watch()
517
530
  return
518
531
  reply.raise_for_status()
519
532
  except httpx.HTTPError as exc:
533
+ self._active_request_id = None
520
534
  self._emit("error", message=format_http_error("Reply", exc))
535
+ self._start_idle_watch()
521
536
  return
522
537
 
523
538
  self._emit("turn_started", request_id=request_id)
524
- await self._run_turn(request_id)
539
+ try:
540
+ await self._run_turn(request_id)
541
+ finally:
542
+ if self._active_request_id is None and not self._closed:
543
+ self._start_idle_watch()
525
544
 
526
545
  async def _dequeue_payload(self) -> str | None | Literal[False]:
527
546
  """Return payload, None for stream end, False on poll timeout."""
@@ -544,9 +563,13 @@ class ChatSessionController:
544
563
  self._warn(f"skipping unparseable event: {exc}")
545
564
  return None
546
565
 
547
- async def _run_turn(self, request_id: str) -> None:
566
+ async def _run_turn(self, request_id: str, *, prelude: list[Any] | None = None) -> None:
548
567
  assert self._client is not None and self.session_id is not None
549
568
  state = _TurnState()
569
+ for evt in prelude or []:
570
+ if state.done or self._turn_abort.is_set():
571
+ break
572
+ await self._dispatch_turn_event(evt, request_id, state)
550
573
  while not state.done and not self._turn_abort.is_set():
551
574
  payload = await self._dequeue_payload()
552
575
  if payload is False:
@@ -577,6 +600,71 @@ class ChatSessionController:
577
600
  if not self._stream_alive and not self._reconnecting:
578
601
  self._emit("stream_ended")
579
602
 
603
+ def _start_idle_watch(self) -> None:
604
+ """Render turns the gateway starts while the prompt is idle."""
605
+ if self._closed or self._active_request_id is not None:
606
+ return
607
+ task = self._idle_task
608
+ if task is not None and not task.done():
609
+ return
610
+ self._idle_task = asyncio.create_task(self._watch_idle(), name="chat-idle-watch")
611
+
612
+ async def _stop_idle_watch(self) -> None:
613
+ task = self._idle_task
614
+ if task is None or task.done():
615
+ self._idle_task = None
616
+ return
617
+ if self._active_request_id is not None:
618
+ return
619
+ task.cancel()
620
+ with contextlib.suppress(asyncio.CancelledError):
621
+ await task
622
+ self._idle_task = None
623
+
624
+ async def _watch_idle(self) -> None:
625
+ """Show a background-subagent wake (or any other turn) the user did not send."""
626
+ try:
627
+ while not self._closed and self._active_request_id is None:
628
+ await self._play_deferred()
629
+ if self._closed or self._active_request_id is not None:
630
+ return
631
+ payload = await self._dequeue_payload()
632
+ if payload is False:
633
+ continue
634
+ if payload is None:
635
+ return
636
+ if self._active_request_id is not None:
637
+ self._event_queue.put_nowait(payload)
638
+ return
639
+ evt = self._decode_event(payload)
640
+ if evt is None:
641
+ continue
642
+ rid = getattr(evt, "request_id", "") or ""
643
+ if not rid or rid in self._abandoned:
644
+ continue
645
+ self._deferred.append(evt)
646
+ except asyncio.CancelledError:
647
+ raise
648
+
649
+ async def _play_deferred(self) -> None:
650
+ while self._deferred and not self._closed and self._active_request_id is None:
651
+ first = self._deferred.pop(0)
652
+ rid = getattr(first, "request_id", "") or ""
653
+ if not rid or rid in self._abandoned:
654
+ continue
655
+ batch = [first]
656
+ rest: list[Any] = []
657
+ for evt in self._deferred:
658
+ if getattr(evt, "request_id", "") == rid:
659
+ batch.append(evt)
660
+ else:
661
+ rest.append(evt)
662
+ self._deferred = rest
663
+ self._active_request_id = rid
664
+ self._turn_abort.clear()
665
+ self._emit("turn_started", request_id=rid)
666
+ await self._run_turn(rid, prelude=batch)
667
+
580
668
  async def _dispatch_turn_event(self, evt: Any, request_id: str, state: _TurnState) -> None:
581
669
  evt_rid = getattr(evt, "request_id", "") or ""
582
670
 
@@ -589,8 +677,9 @@ class ChatSessionController:
589
677
  self._emit("thinking", text="thinking…")
590
678
  return
591
679
 
592
- if evt_rid != request_id:
593
- self._maybe_thinking_trace(evt, request_id)
680
+ if evt_rid and evt_rid != request_id:
681
+ if evt_rid not in self._abandoned:
682
+ self._deferred.append(evt)
594
683
  return
595
684
 
596
685
  if isinstance(evt, GroundingEvent):
@@ -885,6 +974,7 @@ class ChatSessionController:
885
974
  if self._active_request_id is not None:
886
975
  self._abandoned.append(self._active_request_id)
887
976
  self._active_request_id = None
977
+ await self._stop_idle_watch()
888
978
 
889
979
  if self._stream_task is not None:
890
980
  self._stream_task.cancel()
@@ -913,6 +1003,7 @@ class ChatSessionController:
913
1003
  await self._teardown_stream()
914
1004
  await self._create_session()
915
1005
  self._stream_task = asyncio.create_task(self._consume_stream())
1006
+ self._start_idle_watch()
916
1007
  self._emit("session_ready", session_id=self.session_id)
917
1008
  self._emit("connection_state", state="connected")
918
1009
 
@@ -926,6 +1017,7 @@ class ChatSessionController:
926
1017
  await self._teardown_stream()
927
1018
  await self._attach_or_create_session(sid)
928
1019
  self._stream_task = asyncio.create_task(self._consume_stream())
1020
+ self._start_idle_watch()
929
1021
  self._emit("session_ready", session_id=self.session_id)
930
1022
  self._emit("connection_state", state="connected")
931
1023
  await self._emit_transcript_backfill()
@@ -942,6 +1034,11 @@ class ChatSessionController:
942
1034
  if self._closed:
943
1035
  return
944
1036
  self._closed = True
1037
+ if self._idle_task is not None:
1038
+ self._idle_task.cancel()
1039
+ with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, TimeoutError):
1040
+ await asyncio.wait_for(self._idle_task, timeout=0.5)
1041
+ self._idle_task = None
945
1042
  if self._stream_task is not None:
946
1043
  self._stream_task.cancel()
947
1044
  with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, TimeoutError):
@@ -57,6 +57,7 @@ from monkeybot_cli.chat_tui_widgets import (
57
57
  ToolCallBlock,
58
58
  TranscriptPane,
59
59
  UserTurn,
60
+ activity_label,
60
61
  write_osc52_clipboard,
61
62
  )
62
63
  from monkeybot_cli.config_resolve import load_config_doc, resolve_config
@@ -360,6 +361,7 @@ class ChatApp(App[int]):
360
361
  self._assistant: AssistantTurn | None = None
361
362
  self._thinking: ThinkingLine | None = None
362
363
  self._thinking_trace: ThinkingTrace | None = None
364
+ self._status_note = ""
363
365
  self._hitl_card: HitlCard | None = None
364
366
  self._hitl_status_flash: str | None = None
365
367
  self._ring_text = "[dim]○ 0%[/]"
@@ -433,7 +435,10 @@ class ChatApp(App[int]):
433
435
  if self._hitl_status_flash:
434
436
  parts.append(self._hitl_status_flash)
435
437
  if self._turn_active and not self._hitl_active:
436
- parts.append("working · Esc interrupt")
438
+ if self._status_note:
439
+ parts.append(f"{self._status_note} · Esc interrupt")
440
+ else:
441
+ parts.append("working · Esc interrupt")
437
442
  elif self._hitl_active:
438
443
  if self._hitl_kind == "elicit":
439
444
  parts.append("Enter submit · Ctrl-C cancel")
@@ -647,8 +652,24 @@ class ChatApp(App[int]):
647
652
  transcript.mount(earlier, before=0)
648
653
  earlier.absorb(digest)
649
654
 
655
+ def _mark_activity_lead(self, widget: object) -> None:
656
+ """First row of a thought/tool/search stack gets the gap above it."""
657
+ if not isinstance(widget, (ThinkingTrace, ToolCallBlock, GroundingBlock)):
658
+ return
659
+ prev = None
660
+ for child in reversed(self._transcript().children):
661
+ if child is widget or getattr(child, "id", None) == "empty-hint":
662
+ continue
663
+ if isinstance(child, ThinkingTrace) and not child._raw.strip():
664
+ continue
665
+ prev = child
666
+ break
667
+ if not isinstance(prev, (ThinkingTrace, ToolCallBlock, GroundingBlock)):
668
+ widget.add_class("-lead")
669
+
650
670
  def _mount(self, widget: object) -> None:
651
671
  self._hide_empty_hint()
672
+ self._mark_activity_lead(widget)
652
673
  if self._is_counted_turn(widget):
653
674
  self._trim_old_turns()
654
675
  self._turn_count += 1
@@ -673,10 +694,33 @@ class ChatApp(App[int]):
673
694
  self._thinking.remove()
674
695
  self._thinking = None
675
696
 
697
+ def _drop_empty_thinking(self) -> None:
698
+ trace = self._thinking_trace
699
+ if trace is None or not trace.is_attached or trace._raw.strip():
700
+ return
701
+ already = bool(getattr(trace, "_trimmed", False))
702
+ trace._trimmed = True # type: ignore[attr-defined]
703
+ trace.remove()
704
+ self._thinking_trace = None
705
+ if not already:
706
+ self._turn_count = max(0, self._turn_count - 1)
707
+
708
+ def _ensure_live_thinking(self) -> None:
709
+ trace = self._thinking_trace
710
+ if trace is not None and trace.is_attached and not trace.has_class("-done"):
711
+ return
712
+ self._clear_thinking()
713
+ block = ThinkingTrace()
714
+ self._thinking_trace = block
715
+ self._mount(block)
716
+
676
717
  def _finish_thinking_trace(self, *, clear: bool = False) -> None:
677
- if self._thinking_trace is not None and self._thinking_trace.is_attached:
678
- if not self._thinking_trace.has_class("-done"):
679
- self._thinking_trace.finish()
718
+ trace = self._thinking_trace
719
+ if trace is not None and trace.is_attached:
720
+ if not trace._raw.strip():
721
+ self._drop_empty_thinking()
722
+ elif not trace.has_class("-done"):
723
+ trace.finish()
680
724
  if clear:
681
725
  self._thinking_trace = None
682
726
 
@@ -973,7 +1017,11 @@ class ChatApp(App[int]):
973
1017
  self._follow_scroll()
974
1018
 
975
1019
  def _ev_thinking(self, p: dict) -> None:
976
- self._show_thinking(str(p.get("text") or "thinking…"))
1020
+ text = str(p.get("text") or "thinking…")
1021
+ if text.strip().lower() in {"thinking…", "thinking...", "thinking"}:
1022
+ self._ensure_live_thinking()
1023
+ return
1024
+ self._show_thinking(text)
977
1025
 
978
1026
  def _ev_thinking_clear(self, _p: dict) -> None:
979
1027
  self._clear_thinking()
@@ -981,13 +1029,16 @@ class ChatApp(App[int]):
981
1029
  def _ev_turn_started(self, _p: dict) -> None:
982
1030
  self._turn_active = True
983
1031
  self._assistant = None
1032
+ self._status_note = ""
984
1033
  self._seal_thinking_trace()
985
1034
  self._clear_open_tools()
986
- self._show_thinking("thinking…")
1035
+ self._clear_thinking()
1036
+ self._ensure_live_thinking()
987
1037
  self._refresh_status()
988
1038
 
989
1039
  def _ev_assistant_start(self, _p: dict) -> None:
990
1040
  self._clear_thinking()
1041
+ self._drop_empty_thinking()
991
1042
 
992
1043
  def _ev_assistant_delta(self, p: dict) -> None:
993
1044
  delta = str(p.get("delta", ""))
@@ -1027,30 +1078,49 @@ class ChatApp(App[int]):
1027
1078
 
1028
1079
  def _ev_summarizing(self, p: dict) -> None:
1029
1080
  tokens = int(p.get("tokens") or 0)
1030
- self._show_thinking(f"summarizing context ({tokens:,} tokens)")
1081
+ self._drop_empty_thinking()
1082
+ self._clear_thinking()
1083
+ self._status_note = f"summarizing context ({tokens:,} tokens)"
1084
+ self._refresh_status()
1031
1085
 
1032
1086
  def _ev_summarized(self, p: dict) -> None:
1033
1087
  turns = int(p.get("turns") or 0)
1034
1088
  noun = "turn" if turns == 1 else "turns"
1035
- self._show_thinking(f"summarized {turns} {noun}")
1089
+ self._status_note = f"summarized {turns} {noun}"
1090
+ self._refresh_status()
1036
1091
 
1037
1092
  def _ev_grounding(self, p: dict) -> None:
1038
1093
  self._clear_thinking()
1094
+ self._drop_empty_thinking()
1039
1095
  queries = p.get("search_queries") or []
1040
- lines = ["**grounded search**"]
1041
- if queries:
1042
- q = ", ".join(f'"{q}"' for q in queries)
1043
- lines[0] += f" — {q}"
1044
- for source in (p.get("sources") or [])[:5]:
1045
- if not isinstance(source, dict):
1046
- continue
1096
+ raw_sources = p.get("sources") or []
1097
+ sources = [source for source in raw_sources if isinstance(source, dict)]
1098
+ lines: list[str] = []
1099
+ shown = 0
1100
+ linked = 0
1101
+ for source in sources:
1047
1102
  title = str(source.get("title") or "").strip()
1048
1103
  uri = str(source.get("uri") or "").strip()
1049
1104
  if not uri:
1050
1105
  continue
1106
+ linked += 1
1107
+ if shown >= 5:
1108
+ continue
1109
+ shown += 1
1051
1110
  label = title or uri
1052
1111
  lines.append(f"- [{label}]({uri})")
1053
- self._mount(GroundingBlock("\n".join(lines)))
1112
+ query_text = ", ".join(str(query).strip() for query in queries if str(query).strip())
1113
+ if query_text and linked:
1114
+ noun = "source" if linked == 1 else "sources"
1115
+ hint = f"{query_text} · {linked} {noun}"
1116
+ elif query_text:
1117
+ hint = query_text
1118
+ elif linked:
1119
+ noun = "source" if linked == 1 else "sources"
1120
+ hint = f"{linked} {noun}"
1121
+ else:
1122
+ hint = ""
1123
+ self._mount(GroundingBlock("\n".join(lines), title=activity_label("Web", hint)))
1054
1124
 
1055
1125
  def _ev_turn_error(self, p: dict) -> None:
1056
1126
  self._close_assistant()
@@ -1157,9 +1227,10 @@ class ChatApp(App[int]):
1157
1227
  def _ev_stream_ended(self, _p: dict) -> None:
1158
1228
  self._exit_code = 1 if self._controller.stream_error else self._exit_code
1159
1229
 
1160
- def _ev_thinking_trace(self, p: dict) -> None:
1161
- # Legacy --show-thinking path; prefer thinking_block_* when present.
1162
- self._show_thinking(f"thinking {p.get('text')}")
1230
+ def _ev_thinking_trace(self, _p: dict) -> None:
1231
+ # thinking_block_* owns the collapsed row. This legacy event would
1232
+ # print the trace inline.
1233
+ return
1163
1234
 
1164
1235
  def _ev_thinking_block_delta(self, p: dict) -> None:
1165
1236
  text = str(p.get("text") or "")
@@ -1230,6 +1301,7 @@ class ChatApp(App[int]):
1230
1301
  def _finish_turn_ui(self) -> None:
1231
1302
  self._turn_active = False
1232
1303
  self._assistant = None
1304
+ self._status_note = ""
1233
1305
  self._clear_open_tools()
1234
1306
  self._refresh_status()
1235
1307
  self.query_one("#prompt", Composer).focus()