comate-cli 0.5.0__tar.gz → 0.5.2__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 (133) hide show
  1. {comate_cli-0.5.0 → comate_cli-0.5.2}/PKG-INFO +2 -1
  2. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/mcp_cli.py +20 -4
  3. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/app.py +1 -1
  4. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/event_renderer.py +107 -28
  5. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/logging_adapter.py +156 -12
  6. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/rewind_store.py +3 -1
  7. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tool_view.py +40 -17
  8. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui.py +22 -7
  9. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/commands.py +41 -10
  10. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/history_sync.py +10 -2
  11. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/mcp_connecting_view.py +15 -9
  12. comate_cli-0.5.2/memory_system_tengu_moth_copse_report.md +2366 -0
  13. {comate_cli-0.5.0 → comate_cli-0.5.2}/pyproject.toml +6 -1
  14. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_event_renderer.py +75 -10
  15. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_history_sync.py +107 -0
  16. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_input_history.py +46 -5
  17. comate_cli-0.5.2/tests/test_logging_adapter.py +356 -0
  18. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_mcp_slash_command.py +111 -0
  19. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_rewind_store.py +125 -11
  20. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_tool_view.py +4 -4
  21. {comate_cli-0.5.0 → comate_cli-0.5.2}/uv.lock +82 -2
  22. comate_cli-0.5.2//346/267/261/345/272/246Agent/345/217/257/350/247/206/345/214/226UI/350/256/276/350/256/241/346/200/235/350/267/257.md +3173 -0
  23. comate_cli-0.5.0/tests/test_logging_adapter.py +0 -68
  24. {comate_cli-0.5.0 → comate_cli-0.5.2}/.gitignore +0 -0
  25. {comate_cli-0.5.0 → comate_cli-0.5.2}/README.md +0 -0
  26. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/__init__.py +0 -0
  27. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/__main__.py +0 -0
  28. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/main.py +0 -0
  29. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/__init__.py +0 -0
  30. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/animations.py +0 -0
  31. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/assistant_render.py +0 -0
  32. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/codenames.py +0 -0
  33. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  34. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/env_utils.py +0 -0
  35. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/error_display.py +0 -0
  36. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  37. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/history_printer.py +0 -0
  38. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/input_geometry.py +0 -0
  39. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  40. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/logo.py +0 -0
  41. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/markdown_render.py +0 -0
  42. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/mention_completer.py +0 -0
  43. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/message_style.py +0 -0
  44. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/models.py +0 -0
  45. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/path_context_hint.py +0 -0
  46. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/__init__.py +0 -0
  47. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/__init__.py +0 -0
  48. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/detail_view.py +0 -0
  49. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/plugin_list.py +0 -0
  50. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/search_box.py +0 -0
  51. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/tab_bar.py +0 -0
  52. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/marketplace_install_view.py +0 -0
  53. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/plugin_picker.py +0 -0
  54. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/__init__.py +0 -0
  55. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/discover_tab.py +0 -0
  56. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/errors_tab.py +0 -0
  57. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/installed_tab.py +0 -0
  58. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/marketplaces_tab.py +0 -0
  59. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/preflight.py +0 -0
  60. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/question_view.py +0 -0
  61. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/resume_selector.py +0 -0
  62. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  63. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  64. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/selection_menu.py +0 -0
  65. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/session_view.py +0 -0
  66. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/slash_commands.py +0 -0
  67. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/startup.py +0 -0
  68. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/status_bar.py +0 -0
  69. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/text_effects.py +0 -0
  70. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tips.py +0 -0
  71. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  72. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  73. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  74. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
  75. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  76. {comate_cli-0.5.0 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  77. {comate_cli-0.5.0 → comate_cli-0.5.2}/docs/superpowers/plans/2026-04-03-phrase-shuffle.md +0 -0
  78. {comate_cli-0.5.0 → comate_cli-0.5.2}/docs/superpowers/specs/2026-04-01-conditional-diff-subtitle-design.md +0 -0
  79. {comate_cli-0.5.0 → comate_cli-0.5.2}/docs/superpowers/specs/2026-04-03-phrase-shuffle-design.md +0 -0
  80. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/conftest.py +0 -0
  81. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_animator_shuffle.py +0 -0
  82. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_app_mcp_preload.py +0 -0
  83. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_app_preflight_gate.py +0 -0
  84. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_app_print_mode.py +0 -0
  85. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_app_shutdown.py +0 -0
  86. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_app_usage_line.py +0 -0
  87. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_cli_project_root.py +0 -0
  88. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_compact_command_semantics.py +0 -0
  89. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_completion_context_activation.py +0 -0
  90. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_completion_status_panel.py +0 -0
  91. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_context_command.py +0 -0
  92. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_custom_slash_commands.py +0 -0
  93. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_discover_tab.py +0 -0
  94. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_errors_tab.py +0 -0
  95. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_format_error.py +0 -0
  96. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_handle_error.py +0 -0
  97. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_history_printer.py +0 -0
  98. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_input_behavior.py +0 -0
  99. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_installed_tab.py +0 -0
  100. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_interrupt_exit_semantics.py +0 -0
  101. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_layout_coordinator.py +0 -0
  102. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_logo.py +0 -0
  103. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_main_args.py +0 -0
  104. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_marketplaces_tab.py +0 -0
  105. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_mcp_cli.py +0 -0
  106. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_mention_completer.py +0 -0
  107. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_path_context_hint.py +0 -0
  108. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_plugin_slash_commands.py +0 -0
  109. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_plugin_tui_components.py +0 -0
  110. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_preflight.py +0 -0
  111. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_preflight_copilot.py +0 -0
  112. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_question_key_bindings.py +0 -0
  113. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_question_view.py +0 -0
  114. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_resume_selector.py +0 -0
  115. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_rewind_command_semantics.py +0 -0
  116. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_rpc_protocol.py +0 -0
  117. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_rpc_stdio_bridge.py +0 -0
  118. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_selection_menu.py +0 -0
  119. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_skills_slash_command.py +0 -0
  120. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_slash_argument_hint.py +0 -0
  121. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_slash_completer.py +0 -0
  122. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_slash_registry.py +0 -0
  123. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_status_bar.py +0 -0
  124. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_status_bar_transient.py +0 -0
  125. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_task_panel_format.py +0 -0
  126. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_task_panel_key_bindings.py +0 -0
  127. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_task_panel_rendering.py +0 -0
  128. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_task_poll.py +0 -0
  129. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_tui_elapsed_status.py +0 -0
  130. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_tui_mcp_init_gate.py +0 -0
  131. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_tui_paste_placeholder.py +0 -0
  132. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_tui_split_invariance.py +0 -0
  133. {comate_cli-0.5.0 → comate_cli-0.5.2}/tests/test_update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comate-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Comate terminal CLI built on comate-agent-sdk
5
5
  Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
6
6
  Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: >=3.11
17
17
  Requires-Dist: charset-normalizer==3.4.7
18
18
  Requires-Dist: comate-agent-sdk<1.0.0,>=0.0.2
19
+ Requires-Dist: concurrent-log-handler>=0.9.25
19
20
  Requires-Dist: curl-cffi==0.13.0
20
21
  Requires-Dist: packaging>=21.0
21
22
  Requires-Dist: pillow==12.2.0
@@ -217,9 +217,17 @@ def _read_effective_server_with_source(
217
217
  return None
218
218
 
219
219
 
220
- def _render_health_status(connected: bool, reason: str | None) -> str:
221
- if connected:
220
+ def _render_health_status(
221
+ *,
222
+ status: str | None,
223
+ connected: bool,
224
+ reason: str | None,
225
+ ) -> str:
226
+ normalized = str(status or "").strip().lower()
227
+ if normalized == "connected" or (normalized in {"", "idle"} and connected):
222
228
  return "✓ Connected"
229
+ if normalized == "connecting":
230
+ return "… Connecting"
223
231
  if reason:
224
232
  return f"✗ {reason}"
225
233
  return "✗ Not connected"
@@ -294,7 +302,11 @@ def _cmd_list(args: argparse.Namespace, *, project_root: PathInput | None) -> No
294
302
  endpoint = _format_server_endpoint(cfg)
295
303
  health = health_by_alias.get(alias)
296
304
  status = (
297
- _render_health_status(health.connected, health.reason)
305
+ _render_health_status(
306
+ status=getattr(health, "status", None),
307
+ connected=health.connected,
308
+ reason=health.reason,
309
+ )
298
310
  if health is not None
299
311
  else "✗ Unknown"
300
312
  )
@@ -330,7 +342,11 @@ def _cmd_get(args: argparse.Namespace, *, project_root: PathInput | None) -> Non
330
342
  status = "✗ Not connected"
331
343
  if health_rows:
332
344
  row = health_rows[0]
333
- status = _render_health_status(row.connected, row.reason)
345
+ status = _render_health_status(
346
+ status=getattr(row, "status", None),
347
+ connected=row.connected,
348
+ reason=row.reason,
349
+ )
334
350
 
335
351
  server_type = str(cfg.get("type", "stdio")).strip().lower() # type: ignore[attr-defined]
336
352
  lines = [
@@ -370,7 +370,7 @@ async def run(
370
370
  # 而非 fallthrough 到 Python lastResort StreamHandler 以原始文本输出到 stderr。
371
371
  renderer = EventRenderer(project_root=project_root)
372
372
  from comate_cli.terminal_agent.logging_adapter import setup_tui_logging
373
- logging_session = setup_tui_logging(renderer)
373
+ logging_session = setup_tui_logging(renderer, project_root=project_root)
374
374
 
375
375
  session, mode = _resolve_session(agent, resume_session_id, cwd=project_root)
376
376
 
@@ -43,6 +43,7 @@ _DEFAULT_TOOL_PANEL_MAX_LINES = 4
43
43
  _DEFAULT_TASK_PANEL_MAX_LINES = 6
44
44
  _RECENT_TEAM_EVENT_CACHE_SIZE = 128
45
45
  _FILE_REF_MAX_COUNT_BYTES = 10 * 1024 * 1024 # 10 MB
46
+ _SYSTEM_MESSAGE_DEDUPE_WINDOW_SECONDS = 0.5
46
47
 
47
48
 
48
49
  def _truncate(content: str, max_len: int = 120) -> str:
@@ -207,6 +208,37 @@ class EventRenderer:
207
208
  self._recent_team_event_keys: deque[tuple[str, str, str, str, str, str]] = deque(
208
209
  maxlen=_RECENT_TEAM_EVENT_CACHE_SIZE
209
210
  )
211
+ self._last_history_append_at: float = 0.0
212
+
213
+ def _append_history_entry(self, entry: HistoryEntry) -> None:
214
+ self._history.append(entry)
215
+ self._last_history_append_at = time.monotonic()
216
+
217
+ def _should_drop_duplicate_system_message(
218
+ self,
219
+ *,
220
+ content: str,
221
+ severity: Literal["info", "warning", "error"],
222
+ ) -> bool:
223
+ if severity not in {"warning", "error"}:
224
+ return False
225
+ if not self._history:
226
+ return False
227
+ last_entry = self._history[-1]
228
+ if last_entry.entry_type != "system":
229
+ return False
230
+ if last_entry.severity != severity:
231
+ return False
232
+ if str(last_entry.text).strip() != content:
233
+ return False
234
+ if time.monotonic() - self._last_history_append_at > _SYSTEM_MESSAGE_DEDUPE_WINDOW_SECONDS:
235
+ return False
236
+ logger.debug(
237
+ "Skip duplicate system message in scrollback: severity=%s content=%r",
238
+ severity,
239
+ content,
240
+ )
241
+ return True
210
242
 
211
243
  def start_turn(self) -> None:
212
244
  self._flush_assistant_segment()
@@ -218,7 +250,7 @@ class EventRenderer:
218
250
  if not normalized:
219
251
  return
220
252
  self._flush_assistant_segment()
221
- self._history.append(HistoryEntry(entry_type="user", text=normalized))
253
+ self._append_history_entry(HistoryEntry(entry_type="user", text=normalized))
222
254
  self._maybe_append_file_ref_hint(normalized)
223
255
 
224
256
  def _maybe_append_file_ref_hint(self, text: str) -> None:
@@ -230,7 +262,9 @@ class EventRenderer:
230
262
  candidate = self._project_root / raw_path
231
263
  try:
232
264
  if candidate.is_dir():
233
- self._history.append(HistoryEntry(entry_type="file_ref", text=f"Listed directory {raw_path}"))
265
+ self._append_history_entry(
266
+ HistoryEntry(entry_type="file_ref", text=f"Listed directory {raw_path}")
267
+ )
234
268
  return
235
269
  if candidate.is_file():
236
270
  hint = f"Read {raw_path}"
@@ -241,7 +275,7 @@ class EventRenderer:
241
275
  hint += f" ({line_count} lines)"
242
276
  except OSError:
243
277
  pass
244
- self._history.append(HistoryEntry(entry_type="file_ref", text=hint))
278
+ self._append_history_entry(HistoryEntry(entry_type="file_ref", text=hint))
245
279
  return
246
280
  except OSError:
247
281
  continue
@@ -261,7 +295,7 @@ class EventRenderer:
261
295
 
262
296
  def interrupt_turn(self) -> None:
263
297
  if self._running_tools:
264
- self._history.append(
298
+ self._append_history_entry(
265
299
  HistoryEntry(
266
300
  entry_type="system",
267
301
  text=f"Current task interrupted ({len(self._running_tools)} running tools)",
@@ -343,7 +377,7 @@ class EventRenderer:
343
377
  normalized = text.strip()
344
378
  if not normalized:
345
379
  return
346
- self._history.append(HistoryEntry(entry_type="file_ref", text=normalized))
380
+ self._append_history_entry(HistoryEntry(entry_type="file_ref", text=normalized))
347
381
 
348
382
  def append_system_message(
349
383
  self,
@@ -354,8 +388,13 @@ class EventRenderer:
354
388
  normalized = content.strip()
355
389
  if not normalized:
356
390
  return
391
+ if self._should_drop_duplicate_system_message(
392
+ content=normalized,
393
+ severity=severity,
394
+ ):
395
+ return
357
396
  self._flush_assistant_segment()
358
- self._history.append(
397
+ self._append_history_entry(
359
398
  HistoryEntry(entry_type="system", text=normalized, severity=severity)
360
399
  )
361
400
 
@@ -433,14 +472,14 @@ class EventRenderer:
433
472
  if not normalized:
434
473
  return
435
474
  self._flush_assistant_segment()
436
- self._history.append(HistoryEntry(entry_type="elapsed", text=normalized))
475
+ self._append_history_entry(HistoryEntry(entry_type="elapsed", text=normalized))
437
476
 
438
477
  def append_assistant_message(self, content: str) -> None:
439
478
  normalized = content.strip()
440
479
  if not normalized:
441
480
  return
442
481
  self._flush_assistant_segment()
443
- self._history.append(HistoryEntry(entry_type="assistant", text=normalized))
482
+ self._append_history_entry(HistoryEntry(entry_type="assistant", text=normalized))
444
483
 
445
484
  def tool_panel_entries(
446
485
  self, *, max_lines: int | None = None
@@ -554,7 +593,9 @@ class EventRenderer:
554
593
  def _flush_assistant_segment(self) -> None:
555
594
  if not self._assistant_buffer:
556
595
  return
557
- self._history.append(HistoryEntry(entry_type="assistant", text=self._assistant_buffer))
596
+ self._append_history_entry(
597
+ HistoryEntry(entry_type="assistant", text=self._assistant_buffer)
598
+ )
558
599
  self._assistant_buffer = ""
559
600
 
560
601
  def _append_assistant_text(self, text: str) -> None:
@@ -671,7 +712,39 @@ class EventRenderer:
671
712
 
672
713
  if lowered == "grep":
673
714
  lines = [line for line in (result or "").splitlines() if line.strip()]
674
- return f"Found matches in {len(lines)} files"
715
+ if not lines:
716
+ return "Found matches"
717
+
718
+ count_summary = re.search(
719
+ r"^Found\s+(at least\s+)?(\d+)\s+total occurrences across\s+(\d+)\s+(file|files)\.",
720
+ result or "",
721
+ re.MULTILINE,
722
+ )
723
+ if count_summary:
724
+ qualifier = count_summary.group(1) or ""
725
+ occurrences = count_summary.group(2)
726
+ file_count = count_summary.group(3)
727
+ file_word = count_summary.group(4)
728
+ return f"Found {qualifier}{occurrences} matches across {file_count} {file_word}"
729
+
730
+ if lines[0] == "No matches found":
731
+ return "Found 0 matches"
732
+
733
+ if lines[0] == "No files found":
734
+ return "Found matches in 0 files"
735
+
736
+ file_summary = re.match(r"^Found\s+(at least\s+)?(\d+)\s+files?\b", lines[0])
737
+ if file_summary:
738
+ qualifier = file_summary.group(1) or ""
739
+ return f"Found matches in {qualifier}{file_summary.group(2)} files"
740
+
741
+ if all(
742
+ not re.search(r":\d+(?::|$)", line)
743
+ for line in lines
744
+ ):
745
+ return f"Found matches in {len(lines)} files"
746
+
747
+ return "Found matches"
675
748
 
676
749
  return None
677
750
 
@@ -688,7 +761,7 @@ class EventRenderer:
688
761
  Args:
689
762
  signature: 工具签名,例如 "Read(path=xxx)"
690
763
  is_error: 是否为错误结果
691
- diff_lines: optional diff lines for Edit/MultiEdit
764
+ diff_lines: optional diff lines for Edit
692
765
  model_name: model name for Agent tools (rendered dim)
693
766
  subtitle: optional subtitle rendered as `⎿ ...`
694
767
  """
@@ -700,18 +773,18 @@ class EventRenderer:
700
773
  text_obj.append(f" · {model_name}", style="dim")
701
774
  text_obj.append("\n")
702
775
  text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
703
- self._history.append(
776
+ self._append_history_entry(
704
777
  HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
705
778
  )
706
779
  return
707
780
  if model_name:
708
781
  text_obj = Text(signature)
709
782
  text_obj.append(f" · {model_name}", style="dim")
710
- self._history.append(
783
+ self._append_history_entry(
711
784
  HistoryEntry(entry_type="tool_result", text=text_obj, severity=sev, subtitle=subtitle)
712
785
  )
713
786
  return
714
- self._history.append(
787
+ self._append_history_entry(
715
788
  HistoryEntry(
716
789
  entry_type="tool_result",
717
790
  text=signature,
@@ -764,7 +837,9 @@ class EventRenderer:
764
837
  if state is None:
765
838
  display_name = resolve_display_tool_name(tool_name, {})
766
839
  signature = _tool_signature(display_name, "")
767
- self._history.append(HistoryEntry(entry_type="tool_result", text=signature, severity=sev, subtitle=subtitle))
840
+ self._append_history_entry(
841
+ HistoryEntry(entry_type="tool_result", text=signature, severity=sev, subtitle=subtitle)
842
+ )
768
843
  return
769
844
 
770
845
  if state.is_task:
@@ -790,7 +865,7 @@ class EventRenderer:
790
865
  else:
791
866
  display_name = state.display_tool_name or state.tool_name
792
867
  summary = state.args_summary
793
- # Append line range for Edit/MultiEdit
868
+ # Append line range for Edit
794
869
  if display_name == "Update" and metadata:
795
870
  sl = metadata.get("start_line")
796
871
  el = metadata.get("end_line")
@@ -800,7 +875,7 @@ class EventRenderer:
800
875
  signature = _tool_signature(display_name, summary)
801
876
  base = f"{signature}"
802
877
 
803
- # Render diff for Edit/MultiEdit if metadata contains diff lines
878
+ # Render diff for Edit if metadata contains diff lines
804
879
  if not is_error and metadata:
805
880
  diff_lines = metadata.get("diff")
806
881
  if isinstance(diff_lines, list) and len(diff_lines) > 0:
@@ -811,15 +886,19 @@ class EventRenderer:
811
886
  text_obj = Text(base)
812
887
  text_obj.append("\n")
813
888
  text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
814
- self._history.append(
889
+ self._append_history_entry(
815
890
  HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
816
891
  )
817
892
  return
818
893
 
819
894
  if isinstance(base, Text):
820
- self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle))
895
+ self._append_history_entry(
896
+ HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle)
897
+ )
821
898
  else:
822
- self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle))
899
+ self._append_history_entry(
900
+ HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle)
901
+ )
823
902
 
824
903
  @staticmethod
825
904
  def _render_diff_text(diff_lines: list[str], max_lines: int = 50) -> Text:
@@ -900,7 +979,7 @@ class EventRenderer:
900
979
  def _append_questions(self, questions: list[dict[str, Any]]) -> None:
901
980
  if not questions:
902
981
  return
903
- self._history.append(
982
+ self._append_history_entry(
904
983
  HistoryEntry(
905
984
  entry_type="tool_result",
906
985
  text=f"Input required: {len(questions)} question(s) pending.",
@@ -919,7 +998,7 @@ class EventRenderer:
919
998
  if label:
920
999
  labels.append(label)
921
1000
  choice_preview = f" (Options: {' / '.join(labels)})" if labels else ""
922
- self._history.append(
1001
+ self._append_history_entry(
923
1002
  HistoryEntry(
924
1003
  entry_type="tool_result",
925
1004
  text=f" {idx}. {header}: {question_text} {choice_preview}".strip(),
@@ -962,7 +1041,7 @@ class EventRenderer:
962
1041
  if started is not None:
963
1042
  elapsed_suffix = f" · {_format_duration(time.monotonic() - started)}"
964
1043
  total = len(normalized)
965
- self._history.append(
1044
+ self._append_history_entry(
966
1045
  HistoryEntry(
967
1046
  entry_type="tool_result",
968
1047
  text=f"tasks {total}/{total} completed{elapsed_suffix}",
@@ -1042,7 +1121,7 @@ class EventRenderer:
1042
1121
  pass
1043
1122
  case ThinkingEvent(content=thinking):
1044
1123
  self._thinking_content = thinking
1045
- self._history.append(HistoryEntry(entry_type="thinking", text=thinking))
1124
+ self._append_history_entry(HistoryEntry(entry_type="thinking", text=thinking))
1046
1125
  case CompactionResultEvent(
1047
1126
  current_tokens=tokens,
1048
1127
  threshold=threshold,
@@ -1207,7 +1286,7 @@ class EventRenderer:
1207
1286
  if reason == "waiting_for_plan_approval":
1208
1287
  return (False, None)
1209
1288
  if reason == "interrupted":
1210
- self._history.append(
1289
+ self._append_history_entry(
1211
1290
  HistoryEntry(entry_type="system", text="Current task interrupted.", severity="warning")
1212
1291
  )
1213
1292
  case PlanApprovalRequiredEvent(
@@ -1225,18 +1304,18 @@ class EventRenderer:
1225
1304
  except Exception:
1226
1305
  logger.warning("ExitPlanMode: Failed to read plan file %s", plan_path)
1227
1306
  if plan_content:
1228
- self._history.append(
1307
+ self._append_history_entry(
1229
1308
  HistoryEntry(
1230
1309
  entry_type="system",
1231
1310
  text="─── Here is the plan, please review and approve or reject ───",
1232
1311
  )
1233
1312
  )
1234
- self._history.append(HistoryEntry(entry_type="assistant", text=plan_content))
1313
+ self._append_history_entry(HistoryEntry(entry_type="assistant", text=plan_content))
1235
1314
 
1236
1315
  text = f"Plan ready for review: {plan_path}"
1237
1316
  if summary:
1238
1317
  text = f"{text} | {summary}"
1239
- self._history.append(
1318
+ self._append_history_entry(
1240
1319
  HistoryEntry(entry_type="system", text=text)
1241
1320
  )
1242
1321
  case TeamMessageEvent(
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
  import logging
5
5
  import os
6
6
  import sys
7
+ import threading
8
+ import time
9
+ from pathlib import Path
7
10
  from typing import TYPE_CHECKING
8
11
 
9
12
  from prompt_toolkit.application import run_in_terminal
@@ -12,6 +15,37 @@ if TYPE_CHECKING:
12
15
  from comate_cli.terminal_agent.event_renderer import EventRenderer
13
16
 
14
17
 
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _COMATE_LOGGING_SESSION_MARKER = "_comate_tui_logging_session"
21
+ _RECENT_DUPLICATE_LOG_WINDOW_SECONDS = 0.5
22
+ _recent_log_message_times: dict[str, float] = {}
23
+ _recent_log_message_lock = threading.Lock()
24
+ _active_tui_logging_session: "TUILoggingSession | None" = None
25
+ _active_tui_logging_session_lock = threading.Lock()
26
+
27
+ _LOG_LEVEL_ENV_VAR = "COMATE_LOG_LEVEL"
28
+ _VALID_LOG_LEVELS: tuple[str, ...] = ("DEBUG", "INFO", "WARNING", "ERROR")
29
+ _DEFAULT_LOG_LEVEL = logging.INFO
30
+ _invalid_log_level_warned = False
31
+
32
+
33
+ def _should_drop_recent_duplicate_log(msg_key: str) -> bool:
34
+ now = time.monotonic()
35
+ with _recent_log_message_lock:
36
+ expired_keys = [
37
+ key
38
+ for key, ts in _recent_log_message_times.items()
39
+ if now - ts > _RECENT_DUPLICATE_LOG_WINDOW_SECONDS
40
+ ]
41
+ for key in expired_keys:
42
+ _recent_log_message_times.pop(key, None)
43
+
44
+ previous = _recent_log_message_times.get(msg_key)
45
+ _recent_log_message_times[msg_key] = now
46
+ return previous is not None
47
+
48
+
15
49
  class TUILoggingHandler(logging.Handler):
16
50
  """自定义 logging handler,将日志友好地显示在 TUI 中
17
51
 
@@ -40,6 +74,8 @@ class TUILoggingHandler(logging.Handler):
40
74
 
41
75
  # 首次显示检查
42
76
  msg_key = self._get_message_key(record)
77
+ if _should_drop_recent_duplicate_log(msg_key):
78
+ return
43
79
  if msg_key in self._shown_messages:
44
80
  return
45
81
  self._shown_messages.add(msg_key)
@@ -108,9 +144,9 @@ class TUILoggingHandler(logging.Handler):
108
144
  # DummyApplication,直接调用
109
145
  _append()
110
146
  else:
111
- app.create_background_task(
112
- run_in_terminal(_append, in_executor=False)
113
- )
147
+ # run_in_terminal() 自身已经会调度 Future;这里不能再交给
148
+ # create_background_task() 二次包装,否则会触发重复执行竞态。
149
+ run_in_terminal(_append, in_executor=False)
114
150
  except Exception:
115
151
  # 没有 app 或导入失败,直接调用
116
152
  _append()
@@ -159,21 +195,86 @@ class TUILoggingSession:
159
195
  continue
160
196
 
161
197
  self._root_logger.setLevel(self._previous_level)
198
+ with _active_tui_logging_session_lock:
199
+ global _active_tui_logging_session
200
+ if _active_tui_logging_session is self:
201
+ _active_tui_logging_session = None
202
+
203
+
204
+ def _read_level_from_settings_env(project_root: Path | None) -> str:
205
+ """从 settings.json 的 env 段读取 COMATE_LOG_LEVEL。
206
+
207
+ 查找顺序:project settings(.agent/settings.json) → user settings(~/.agent/settings.json)。
208
+ 复用 SDK 侧的 load_settings_file,不引入独立解析路径。
209
+ 返回空串表示未配置或读取失败,由调用方决定如何降级。
210
+ """
211
+ try:
212
+ from comate_agent_sdk.agent.settings import USER_SETTINGS_PATH, load_settings_file
213
+ except Exception:
214
+ return ""
215
+
216
+ def _lookup(path: Path) -> str:
217
+ try:
218
+ cfg = load_settings_file(path)
219
+ except Exception:
220
+ return ""
221
+ if cfg is None or cfg.env is None:
222
+ return ""
223
+ raw = cfg.env.get(_LOG_LEVEL_ENV_VAR, "")
224
+ return raw.strip() if isinstance(raw, str) else ""
225
+
226
+ if project_root is not None:
227
+ value = _lookup(Path(project_root) / ".agent" / "settings.json")
228
+ if value:
229
+ return value
230
+
231
+ return _lookup(USER_SETTINGS_PATH)
232
+
162
233
 
234
+ def _resolve_log_level(project_root: Path | None = None) -> int:
235
+ """解析生效日志级别。
163
236
 
164
- def _build_file_handler() -> logging.Handler:
165
- from logging.handlers import RotatingFileHandler
237
+ 优先级:os.environ[COMATE_LOG_LEVEL]
238
+ > project .agent/settings.json env.COMATE_LOG_LEVEL
239
+ > user ~/.agent/settings.json env.COMATE_LOG_LEVEL
240
+ > INFO。
241
+ 非法值降级 INFO,并在整个进程生命周期内最多 warn 一次。
242
+ """
243
+ global _invalid_log_level_warned
244
+
245
+ raw = os.environ.get(_LOG_LEVEL_ENV_VAR, "").strip()
246
+ if not raw:
247
+ raw = _read_level_from_settings_env(project_root)
248
+
249
+ if not raw:
250
+ return _DEFAULT_LOG_LEVEL
251
+
252
+ normalized = raw.upper()
253
+ if normalized not in _VALID_LOG_LEVELS:
254
+ if not _invalid_log_level_warned:
255
+ _invalid_log_level_warned = True
256
+ logger.warning(
257
+ f"Invalid {_LOG_LEVEL_ENV_VAR}={raw!r}; falling back to INFO. "
258
+ f"Valid values: {list(_VALID_LOG_LEVELS)}"
259
+ )
260
+ return _DEFAULT_LOG_LEVEL
261
+
262
+ return getattr(logging, normalized)
263
+
264
+
265
+ def _build_file_handler(level: int) -> logging.Handler:
266
+ from concurrent_log_handler import ConcurrentRotatingFileHandler
166
267
 
167
268
  log_dir = os.path.join(os.path.expanduser("~"), ".comate", "logs")
168
269
  os.makedirs(log_dir, exist_ok=True)
169
270
  log_path = os.path.join(log_dir, "agent.log")
170
- file_handler = RotatingFileHandler(
271
+ file_handler = ConcurrentRotatingFileHandler(
171
272
  log_path,
172
273
  maxBytes=10 * 1024 * 1024,
173
274
  backupCount=3,
174
275
  encoding="utf-8",
175
276
  )
176
- file_handler.setLevel(logging.DEBUG)
277
+ file_handler.setLevel(level)
177
278
  file_handler.setFormatter(
178
279
  logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
179
280
  )
@@ -187,24 +288,64 @@ def _should_mute_terminal_stream(handler: logging.Handler) -> bool:
187
288
  return stream in {sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__}
188
289
 
189
290
 
190
- def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
291
+ def setup_tui_logging(
292
+ renderer: EventRenderer,
293
+ *,
294
+ project_root: Path | None = None,
295
+ ) -> TUILoggingSession:
191
296
  """统一日志初始化:文件 + TUI 双通道。
192
297
 
193
- - 所有日志(含 traceback)写入 ~/.comate/logs/agent.log(RotatingFileHandler)
298
+ - 所有日志(含 traceback)写入 ~/.comate/logs/agent.log
299
+ (ConcurrentRotatingFileHandler:跨进程文件锁 + Windows rename 重试)
194
300
  - WARNING/ERROR 以用户友好格式显示在 TUI scrollback
195
301
  - 临时静默 root logger 上写往 stdout/stderr 的 stream handler,避免污染 TUI
302
+
303
+ 日志级别通过 COMATE_LOG_LEVEL 控制(默认 INFO,可设为 DEBUG/INFO/WARNING/ERROR)。
304
+ 查找顺序:os.environ > project .agent/settings.json env > user ~/.agent/settings.json env。
305
+ root 与 file handler 同步应用该级别——第三方库(anthropic/httpx/...)的 DEBUG 噪音随之收敛。
306
+ TUI handler 始终固定 WARNING,不受此开关影响,避免 DEBUG 刷屏。
196
307
  """
197
308
  root = logging.getLogger()
198
309
  previous_level = root.level
310
+ level = _resolve_log_level(project_root)
311
+
312
+ with _active_tui_logging_session_lock:
313
+ global _active_tui_logging_session
314
+ active_session = _active_tui_logging_session
315
+ if active_session is not None:
316
+ active_session.close()
317
+ with _recent_log_message_lock:
318
+ _recent_log_message_times.clear()
319
+
320
+ stale_handlers: list[logging.Handler] = []
321
+ for handler in list(root.handlers):
322
+ if not getattr(handler, _COMATE_LOGGING_SESSION_MARKER, False):
323
+ continue
324
+ try:
325
+ root.removeHandler(handler)
326
+ except Exception:
327
+ pass
328
+ stale_handlers.append(handler)
329
+
330
+ for handler in stale_handlers:
331
+ try:
332
+ handler.close()
333
+ except Exception:
334
+ continue
199
335
 
200
336
  # 1. 日志文件 handler(完整调试信息,含 traceback)
201
- file_handler = _build_file_handler()
337
+ file_handler = _build_file_handler(level)
338
+ setattr(file_handler, _COMATE_LOGGING_SESSION_MARKER, True)
202
339
  root.addHandler(file_handler)
203
- root.setLevel(logging.DEBUG)
340
+ # root 级别与 file handler 保持一致——这是单 knob 控第三方 DEBUG 噪音的关键:
341
+ # 第三方 logger level=NOTSET 会继承 root 的 effective level,DEBUG 记录
342
+ # 在 isEnabledFor 阶段就被丢弃,不会走到任何 handler。
343
+ root.setLevel(level)
204
344
 
205
345
  # 2. TUI handler 挂到 root(覆盖所有命名空间的 WARNING/ERROR)
206
346
  tui_handler = TUILoggingHandler(renderer)
207
347
  tui_handler.setLevel(logging.WARNING)
348
+ setattr(tui_handler, _COMATE_LOGGING_SESSION_MARKER, True)
208
349
  root.addHandler(tui_handler)
209
350
 
210
351
  # 3. 临时静默直写终端的 handler,避免污染 prompt_toolkit UI
@@ -218,9 +359,12 @@ def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
218
359
  handler.addFilter(log_filter)
219
360
  muted_handlers.append((handler, log_filter))
220
361
 
221
- return TUILoggingSession(
362
+ session = TUILoggingSession(
222
363
  root_logger=root,
223
364
  added_handlers=[file_handler, tui_handler],
224
365
  muted_handlers=muted_handlers,
225
366
  previous_level=previous_level,
226
367
  )
368
+ with _active_tui_logging_session_lock:
369
+ _active_tui_logging_session = session
370
+ return session
@@ -16,7 +16,7 @@ from comate_agent_sdk.context.items import ItemType
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
- _TRACKED_TOOLS = {"Write", "Edit", "MultiEdit"}
19
+ _TRACKED_TOOLS = {"Write", "Edit"}
20
20
  _SCHEMA_VERSION = 1
21
21
 
22
22
 
@@ -561,6 +561,8 @@ class RewindStore:
561
561
  touched.add(relpath)
562
562
 
563
563
  created_val = data.get("created")
564
+ if not isinstance(created_val, bool):
565
+ created_val = meta.get("created")
564
566
  created: bool | None
565
567
  if isinstance(created_val, bool):
566
568
  created = created_val