monkeybot-cli 0.11.1__tar.gz → 0.12.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.12.0}/.gitignore +3 -0
  2. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/PKG-INFO +1 -1
  3. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/pyproject.toml +1 -1
  4. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_tui.py +91 -19
  5. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_tui_widgets.py +152 -51
  6. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_tui.py +20 -3
  7. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/uv.lock +3 -2
  8. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/skills/monkeybot/SKILL.md +0 -0
  9. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/skills/monkeybot/references/config-sections.md +0 -0
  10. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/__init__.py +0 -0
  11. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_file_index.py +0 -0
  12. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_local_shell.py +0 -0
  13. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_renderer.py +0 -0
  14. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_session.py +0 -0
  15. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_status_bar.py +0 -0
  16. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_theme.py +0 -0
  17. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/chat_tool_display.py +0 -0
  18. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/__init__.py +0 -0
  19. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/chat.py +0 -0
  20. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/doctor.py +0 -0
  21. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/loop.py +0 -0
  22. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/mcp_login.py +0 -0
  23. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/new.py +0 -0
  24. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/refresh.py +0 -0
  25. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/run_cmd.py +0 -0
  26. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/talk.py +0 -0
  27. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/trace.py +0 -0
  28. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/commands/validate.py +0 -0
  29. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/compat.py +0 -0
  30. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/config_resolve.py +0 -0
  31. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/exit_commands.py +0 -0
  32. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/extras_catalog.py +0 -0
  33. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/gateway_health.py +0 -0
  34. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/main.py +0 -0
  35. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/opensandbox_lifecycle.py +0 -0
  36. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/output.py +0 -0
  37. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/overrides.txt +0 -0
  38. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/process_tree.py +0 -0
  39. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/providers.py +0 -0
  40. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/__init__.py +0 -0
  41. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/audio_io.py +0 -0
  42. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/client.py +0 -0
  43. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
  44. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/push_to_talk.py +0 -0
  45. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/session.py +0 -0
  46. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/session_controller.py +0 -0
  47. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/talk_ui.py +0 -0
  48. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
  49. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/runtime_python.py +0 -0
  50. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold.py +0 -0
  51. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
  52. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
  53. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
  54. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -0
  55. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +0 -0
  56. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
  57. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/env.example +0 -0
  58. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/goal/SKILL.md +0 -0
  59. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +0 -0
  60. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
  61. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
  62. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
  63. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +0 -0
  64. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
  65. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
  66. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
  67. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/AGENT.md +0 -0
  68. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/command_allowlist.yaml +0 -0
  69. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/monkeybot.overrides.yaml +0 -0
  70. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/scaffold_defaults/templates/coding/permissions.yaml +0 -0
  71. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/session_controller.py +0 -0
  72. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/src/monkeybot_cli/terminal_markdown.py +0 -0
  73. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_continue.py +0 -0
  74. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_e2e.py +0 -0
  75. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_errors.py +0 -0
  76. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_file_index.py +0 -0
  77. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_grounding.py +0 -0
  78. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_local_shell.py +0 -0
  79. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_renderer_parity.py +0 -0
  80. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_session.py +0 -0
  81. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_status_bar.py +0 -0
  82. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_theme.py +0 -0
  83. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_chat_tool_display.py +0 -0
  84. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_cli.py +0 -0
  85. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_config_resolve.py +0 -0
  86. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_doctor.py +0 -0
  87. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_extras_catalog.py +0 -0
  88. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_gateway_health.py +0 -0
  89. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_opensandbox_lifecycle.py +0 -0
  90. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_realtime_session_controller.py +0 -0
  91. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_run_cmd.py +0 -0
  92. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_runtime_python.py +0 -0
  93. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_scaffold.py +0 -0
  94. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.0}/tests/test_terminal_markdown.py +0 -0
  95. {monkeybot_cli-0.11.1 → monkeybot_cli-0.12.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.12.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.12.0"
8
8
  description = "CLI to create, configure, validate, and chat with monkeybot agents."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -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()
@@ -39,6 +39,38 @@ def _plain_title(text: str) -> Content:
39
39
  return Content.from_text(text, markup=False)
40
40
 
41
41
 
42
+ _KIND_WIDTH = 7
43
+ _HINT_MAX = 72
44
+
45
+
46
+ def activity_label(kind: str, hint: str = "") -> str:
47
+ """One activity row: kind padded so hints share a column (``Thought …``)."""
48
+ kind = " ".join(kind.split())
49
+ hint = " ".join(hint.split())
50
+ if len(hint) > _HINT_MAX:
51
+ hint = hint[:_HINT_MAX] + "…"
52
+ if not hint:
53
+ return kind
54
+ return f"{kind:<{_KIND_WIDTH}} {hint}"
55
+
56
+
57
+ def activity_label_from_title(title: str) -> str:
58
+ """Align a ``Kind hint`` title that ``tool_collapsed_title`` already built."""
59
+ kind, sep, hint = title.partition(" ")
60
+ if not sep:
61
+ return activity_label(kind)
62
+ return activity_label(kind, hint)
63
+
64
+
65
+ def _set_collapsed_mark(widget: Collapsible, mark: str) -> None:
66
+ """The disclosure symbol is the row's only glyph (spinner, ▸, ✓, ✗)."""
67
+ title = widget._title
68
+ if title.collapsed_symbol == mark:
69
+ return
70
+ title.collapsed_symbol = mark
71
+ title._update_label()
72
+
73
+
42
74
  def write_osc52_clipboard(app: App[object], text: str) -> bool:
43
75
  """Best-effort OSC 52 clipboard write via the Textual driver."""
44
76
  try:
@@ -110,59 +142,105 @@ class ThinkingLine(Static):
110
142
  self.update(Text(f"{self._label} {elapsed}s", style="dim italic"))
111
143
 
112
144
 
113
- class ThinkingTrace(Vertical):
114
- """OpenCode-style thinking block: ``Thinking...`` / body / ``...done thinking.``"""
145
+ class ThinkingTrace(Collapsible):
146
+ """Collapsed thinking row. Click it (or focus it and press Enter) to read the trace."""
115
147
 
116
148
  DEFAULT_CSS = """
117
149
  ThinkingTrace {
118
150
  width: 1fr;
119
151
  height: auto;
120
- margin-top: 1;
152
+ background: transparent;
153
+ border-top: none;
121
154
  padding: 0 1;
122
- }
123
- ThinkingTrace > .header {
124
- height: 1;
155
+ margin-top: 0;
125
156
  color: $muted;
126
- text-style: bold dim;
127
157
  }
128
- ThinkingTrace > .body {
129
- height: auto;
130
- color: $muted;
131
- text-style: dim;
158
+ ThinkingTrace.-lead {
159
+ margin-top: 1;
132
160
  }
133
- ThinkingTrace > .footer {
134
- height: 1;
135
- color: $muted;
136
- text-style: bold dim;
137
- display: none;
161
+ ThinkingTrace > Contents {
162
+ width: 1fr;
163
+ height: auto;
164
+ padding: 0 1 1 3;
165
+ color: $disabled;
138
166
  }
139
- ThinkingTrace.-done > .footer {
140
- display: block;
167
+ ThinkingTrace .thinking-body {
168
+ width: 1fr;
169
+ height: auto;
170
+ margin: 0;
171
+ padding: 0;
172
+ background: transparent;
173
+ color: $disabled;
141
174
  }
142
175
  """
143
176
 
144
177
  def __init__(self, **kwargs: object) -> None:
145
- super().__init__(**kwargs) # type: ignore[arg-type]
146
178
  self._raw = ""
179
+ self._done = False
180
+ self._spin_i = 0
181
+ self._spin_timer: Timer | None = None
182
+ self._body = Static("", classes="thinking-body")
183
+ super().__init__(
184
+ self._body,
185
+ title=_plain_title("Thinking…"), # type: ignore[arg-type]
186
+ collapsed=True,
187
+ collapsed_symbol="▸",
188
+ expanded_symbol="▾",
189
+ **kwargs, # type: ignore[arg-type]
190
+ )
147
191
 
148
- def compose(self) -> ComposeResult:
149
- yield Static("Thinking...", classes="header")
150
- yield Static("", classes="body")
151
- yield Static("...done thinking.", classes="footer")
192
+ def on_mount(self) -> None:
193
+ self._start_spinner()
194
+
195
+ def on_unmount(self) -> None:
196
+ self._stop_spinner()
152
197
 
153
198
  def append_delta(self, chunk: str) -> None:
154
199
  if not chunk:
155
200
  return
156
201
  self._raw += chunk
157
- with contextlib.suppress(NoMatches):
158
- self.query_one(".body", Static).update(Text(self._raw, style="dim"))
202
+ self._body.update(Text(self._raw, style="dim"))
159
203
 
160
204
  def finish(self) -> None:
205
+ self._done = True
206
+ self._stop_spinner()
161
207
  self.add_class("-done")
208
+ _set_collapsed_mark(self, "▸")
209
+ self.title = _plain_title(activity_label("Thought", self._preview())) # type: ignore[assignment]
162
210
 
163
211
  def reopen(self) -> None:
164
212
  """Allow late thinking deltas to continue this block after a premature close."""
213
+ self._done = False
165
214
  self.remove_class("-done")
215
+ self.title = _plain_title("Thinking…") # type: ignore[assignment]
216
+ _set_collapsed_mark(self, "▸")
217
+ self._start_spinner()
218
+
219
+ def _preview(self) -> str:
220
+ return " ".join(self._raw.split())
221
+
222
+ def _start_spinner(self) -> None:
223
+ if self._done or not self.is_mounted:
224
+ return
225
+ self.title = _plain_title("Thinking…") # type: ignore[assignment]
226
+ if not bool(getattr(self.app, "animations_enabled", True)):
227
+ _set_collapsed_mark(self, "▸")
228
+ return
229
+ if self._spin_timer is None:
230
+ self._spin_timer = self.set_interval(0.08, self._tick_spinner)
231
+ self._tick_spinner()
232
+
233
+ def _stop_spinner(self) -> None:
234
+ if self._spin_timer is not None:
235
+ self._spin_timer.stop()
236
+ self._spin_timer = None
237
+
238
+ def _tick_spinner(self) -> None:
239
+ if self._done:
240
+ return
241
+ glyph = _SPIN[self._spin_i % len(_SPIN)]
242
+ self._spin_i += 1
243
+ _set_collapsed_mark(self, glyph)
166
244
 
167
245
 
168
246
  class SystemLine(Static):
@@ -193,32 +271,50 @@ class SystemLine(Static):
193
271
  self.update(Text(self.body, style=style))
194
272
 
195
273
 
196
- class GroundingBlock(Vertical):
197
- """Grounding sources with clickable markdown links."""
274
+ class GroundingBlock(Collapsible):
275
+ """Collapsed web-search row. Open it to read the source links."""
198
276
 
199
277
  DEFAULT_CSS = """
200
278
  GroundingBlock {
201
279
  width: 1fr;
202
280
  height: auto;
203
- margin-top: 1;
281
+ background: transparent;
282
+ border-top: none;
204
283
  padding: 0 1;
284
+ margin-top: 0;
285
+ color: $muted;
286
+ }
287
+ GroundingBlock.-lead {
288
+ margin-top: 1;
205
289
  }
206
- GroundingBlock > Markdown {
290
+ GroundingBlock > Contents {
291
+ width: 1fr;
292
+ height: auto;
293
+ padding: 0 1 1 3;
294
+ color: $disabled;
295
+ }
296
+ GroundingBlock .grounding-body {
297
+ width: 1fr;
207
298
  height: auto;
208
299
  margin: 0;
209
300
  padding: 0;
210
301
  background: transparent;
211
- color: $muted;
302
+ color: $disabled;
212
303
  }
213
304
  """
214
305
 
215
- def __init__(self, markdown: str, **kwargs: object) -> None:
216
- super().__init__(**kwargs) # type: ignore[arg-type]
306
+ def __init__(self, markdown: str, *, title: str = "Web", **kwargs: object) -> None:
217
307
  self.markdown = markdown
218
308
  self.mounted_at = datetime.now()
219
-
220
- def compose(self) -> ComposeResult:
221
- yield Markdown(self.markdown, open_links=True)
309
+ body = Markdown(markdown, open_links=True, classes="grounding-body")
310
+ super().__init__(
311
+ body,
312
+ title=_plain_title(title), # type: ignore[arg-type]
313
+ collapsed=True,
314
+ collapsed_symbol="▸",
315
+ expanded_symbol="▾",
316
+ **kwargs, # type: ignore[arg-type]
317
+ )
222
318
 
223
319
 
224
320
  class EarlierTurns(Collapsible):
@@ -289,7 +385,7 @@ class UserTurn(Static):
289
385
  content = Text()
290
386
  if self.show_timestamp:
291
387
  content.append(self.mounted_at.strftime("%H:%M:%S "), style="dim")
292
- content.append("you\n", style="bold")
388
+ content.append("you\n", style="dim")
293
389
  content.append(self.body)
294
390
  self.update(content)
295
391
 
@@ -304,7 +400,7 @@ class AssistantTurn(Vertical):
304
400
  margin-top: 1;
305
401
  padding: 0 1;
306
402
  }
307
- AssistantTurn > .role {
403
+ AssistantTurn > .stamp {
308
404
  height: 1;
309
405
  color: $muted;
310
406
  text-style: dim;
@@ -335,22 +431,24 @@ class AssistantTurn(Vertical):
335
431
  self.show_timestamp = show_timestamp
336
432
 
337
433
  def compose(self) -> ComposeResult:
338
- yield Static(self._role_label(), classes="role")
434
+ yield Static("", classes="stamp")
339
435
  yield Markdown("", open_links=False, classes="body")
340
436
  yield Static("▍", classes="cursor")
341
437
 
342
- def _role_label(self) -> str:
438
+ def _stamp_text(self) -> str:
343
439
  if self.show_timestamp:
344
- return f"{self.mounted_at.strftime('%H:%M:%S')} assistant"
345
- return "assistant"
440
+ return self.mounted_at.strftime("%H:%M:%S")
441
+ return ""
346
442
 
347
443
  def refresh_label(self) -> None:
348
444
  with contextlib.suppress(NoMatches):
349
- self.query_one(".role", Static).update(self._role_label())
445
+ stamp = self.query_one(".stamp", Static)
446
+ text = self._stamp_text()
447
+ stamp.update(text)
448
+ stamp.display = bool(text)
350
449
 
351
450
  def on_mount(self) -> None:
352
- # Flush timer is started lazily on first delta (and paused when drained).
353
- return
451
+ self.refresh_label()
354
452
 
355
453
  def append_delta(self, chunk: str) -> None:
356
454
  if not chunk:
@@ -403,6 +501,9 @@ class ToolCallBlock(Collapsible):
403
501
  margin-top: 0;
404
502
  color: $muted;
405
503
  }
504
+ ToolCallBlock.-lead {
505
+ margin-top: 1;
506
+ }
406
507
  ToolCallBlock > Contents {
407
508
  width: 1fr;
408
509
  height: auto;
@@ -452,10 +553,10 @@ class ToolCallBlock(Collapsible):
452
553
  )
453
554
  super().__init__(
454
555
  self._detail,
455
- title=_plain_title(f" {title}"), # type: ignore[arg-type]
556
+ title=_plain_title(activity_label_from_title(title)), # type: ignore[arg-type]
456
557
  collapsed=True,
457
- collapsed_symbol="▶",
458
- expanded_symbol="▼",
558
+ collapsed_symbol="▸",
559
+ expanded_symbol="▾",
459
560
  classes="running",
460
561
  **kwargs, # type: ignore[arg-type]
461
562
  )
@@ -466,14 +567,14 @@ class ToolCallBlock(Collapsible):
466
567
  self._spin_timer = self.set_interval(0.08, self._tick_spinner)
467
568
  self._tick_spinner()
468
569
  else:
469
- self.title = _plain_title(f" {self.display_label}") # type: ignore[assignment]
570
+ _set_collapsed_mark(self, "▸")
470
571
 
471
572
  def _tick_spinner(self) -> None:
472
573
  if self.status != "running":
473
574
  return
474
575
  glyph = _SPIN[self._spin_i % len(_SPIN)]
475
576
  self._spin_i += 1
476
- self.title = _plain_title(f" {glyph} {self.display_label}") # type: ignore[assignment]
577
+ _set_collapsed_mark(self, glyph)
477
578
 
478
579
  def mark_finished(self, *, error: object = None, result: str = "") -> None:
479
580
  self.status = "error" if error else "ok"
@@ -482,8 +583,8 @@ class ToolCallBlock(Collapsible):
482
583
  if self._spin_timer is not None:
483
584
  self._spin_timer.stop()
484
585
  self._spin_timer = None
485
- mark = "✗" if error else "✓"
486
- self.title = _plain_title(f" {mark} {self.display_label}") # type: ignore[assignment]
586
+ _set_collapsed_mark(self, "✗" if error else "✓")
587
+ self.title = _plain_title(activity_label_from_title(self.display_label)) # type: ignore[assignment]
487
588
  self._detail.update(
488
589
  format_tool_expand_body(
489
590
  self.tool_name,
@@ -592,6 +592,10 @@ def test_grounding_mounts_markdown_links(tmp_path: Path) -> None:
592
592
  await pilot.pause()
593
593
  blocks = list(app.query(GroundingBlock))
594
594
  assert len(blocks) == 1
595
+ assert blocks[0].collapsed is True
596
+ assert "Web" in str(blocks[0].title)
597
+ assert "monkeys" in str(blocks[0].title)
598
+ assert "1 source" in str(blocks[0].title)
595
599
  assert "https://example.com" in blocks[0].markdown
596
600
  assert "[Docs]" in blocks[0].markdown
597
601
 
@@ -1212,24 +1216,36 @@ def test_thinking_trace_streams_and_finishes(tmp_path: Path) -> None:
1212
1216
  await pilot.pause()
1213
1217
  assert app._thinking_trace is not None
1214
1218
  assert "The user said hello" in app._thinking_trace._raw
1215
- header = app._thinking_trace.query_one(".header")
1216
- assert "Thinking" in str(header.render())
1219
+ assert app._thinking_trace.collapsed is True
1220
+ assert "Thinking" in str(app._thinking_trace.title)
1217
1221
  assert not app._thinking_trace.has_class("-done")
1218
1222
 
1219
1223
  app._ev_thinking_block_delta({"text": " — reply briefly."})
1220
1224
  await pilot.pause()
1221
1225
  assert "reply briefly" in app._thinking_trace._raw
1226
+ assert app._thinking_trace.collapsed is True
1222
1227
 
1223
1228
  app._ev_thinking_block_complete({})
1224
1229
  await pilot.pause()
1225
- # Finished block stays mounted; pointer kept for late re-entry.
1230
+ # Finished block stays mounted and collapsed; pointer kept for late re-entry.
1226
1231
  assert app._thinking_trace is not None
1227
1232
  assert app._thinking_trace.has_class("-done")
1233
+ assert app._thinking_trace.collapsed is True
1234
+ assert "Thought" in str(app._thinking_trace.title)
1235
+ assert "hello" in str(app._thinking_trace.title)
1236
+ assert app._thinking_trace._title.collapsed_symbol == "▸"
1228
1237
  traces = list(app.query(ThinkingTrace))
1229
1238
  assert len(traces) == 1
1230
1239
  assert traces[0].has_class("-done")
1240
+ assert traces[0].collapsed is True
1231
1241
  assert "The user said hello — reply briefly." in traces[0]._raw
1232
1242
 
1243
+ await pilot.click(traces[0].children[0])
1244
+ await pilot.pause()
1245
+ assert traces[0].collapsed is False
1246
+ body = traces[0].query_one(".thinking-body")
1247
+ assert "The user said hello — reply briefly." in str(body.render())
1248
+
1233
1249
  app._ev_turn_complete({})
1234
1250
  await pilot.pause()
1235
1251
  assert app._thinking_trace is None
@@ -1272,6 +1288,7 @@ def test_late_thinking_delta_reopens_same_block(tmp_path: Path) -> None:
1272
1288
  assert len(traces) == 1
1273
1289
  assert "ask what they'd like me to build." in traces[0]._raw
1274
1290
  assert traces[0].has_class("-done")
1291
+ assert traces[0].collapsed is True
1275
1292
 
1276
1293
  # Thinking block must still sit above the assistant turn.
1277
1294
  children = list(app._transcript().children)
@@ -633,7 +633,7 @@ wheels = [
633
633
 
634
634
  [[package]]
635
635
  name = "monkeybot"
636
- version = "3.10.0"
636
+ version = "3.11.0"
637
637
  source = { editable = "../" }
638
638
  dependencies = [
639
639
  { name = "aiosqlite" },
@@ -725,6 +725,7 @@ provides-extras = ["memory", "gemini", "vertex", "claude", "vertex-claude", "bed
725
725
  dev = [
726
726
  { name = "anthropic", extras = ["vertex", "bedrock"], specifier = ">=0.40.0" },
727
727
  { name = "asgi-lifespan", specifier = ">=2.1.0" },
728
+ { name = "jsonschema", specifier = ">=4.26.0" },
728
729
  { name = "monkeybot", extras = ["memory"] },
729
730
  { name = "monkeybot", extras = ["postgres"] },
730
731
  { name = "monkeybot-cli", editable = "." },
@@ -773,7 +774,7 @@ dev = [{ name = "pytest", specifier = ">=8.0.0" }]
773
774
 
774
775
  [[package]]
775
776
  name = "monkeybot-cli"
776
- version = "0.11.0"
777
+ version = "0.11.1"
777
778
  source = { editable = "." }
778
779
  dependencies = [
779
780
  { name = "httpx" },