comate-cli 0.5.0__tar.gz → 0.5.1__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 (131) hide show
  1. {comate_cli-0.5.0 → comate_cli-0.5.1}/PKG-INFO +1 -1
  2. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/event_renderer.py +71 -24
  3. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/logging_adapter.py +65 -4
  4. {comate_cli-0.5.0 → comate_cli-0.5.1}/pyproject.toml +1 -1
  5. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_event_renderer.py +36 -0
  6. comate_cli-0.5.1/tests/test_logging_adapter.py +166 -0
  7. comate_cli-0.5.0/tests/test_logging_adapter.py +0 -68
  8. {comate_cli-0.5.0 → comate_cli-0.5.1}/.gitignore +0 -0
  9. {comate_cli-0.5.0 → comate_cli-0.5.1}/README.md +0 -0
  10. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/__init__.py +0 -0
  11. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/__main__.py +0 -0
  12. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/main.py +0 -0
  13. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/mcp_cli.py +0 -0
  14. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/__init__.py +0 -0
  15. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/animations.py +0 -0
  16. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/app.py +0 -0
  17. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/assistant_render.py +0 -0
  18. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/codenames.py +0 -0
  19. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  20. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/env_utils.py +0 -0
  21. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/error_display.py +0 -0
  22. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  23. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/history_printer.py +0 -0
  24. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/input_geometry.py +0 -0
  25. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  26. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/logo.py +0 -0
  27. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/markdown_render.py +0 -0
  28. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/mention_completer.py +0 -0
  29. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/message_style.py +0 -0
  30. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/models.py +0 -0
  31. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/path_context_hint.py +0 -0
  32. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/__init__.py +0 -0
  33. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/components/__init__.py +0 -0
  34. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/components/detail_view.py +0 -0
  35. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/components/plugin_list.py +0 -0
  36. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/components/search_box.py +0 -0
  37. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/components/tab_bar.py +0 -0
  38. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/marketplace_install_view.py +0 -0
  39. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/plugin_picker.py +0 -0
  40. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/tabs/__init__.py +0 -0
  41. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/tabs/discover_tab.py +0 -0
  42. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/tabs/errors_tab.py +0 -0
  43. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/tabs/installed_tab.py +0 -0
  44. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/plugins/tabs/marketplaces_tab.py +0 -0
  45. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/preflight.py +0 -0
  46. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/question_view.py +0 -0
  47. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/resume_selector.py +0 -0
  48. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/rewind_store.py +0 -0
  49. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  50. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  51. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/selection_menu.py +0 -0
  52. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/session_view.py +0 -0
  53. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/slash_commands.py +0 -0
  54. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/startup.py +0 -0
  55. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/status_bar.py +0 -0
  56. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/text_effects.py +0 -0
  57. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tips.py +0 -0
  58. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tool_view.py +0 -0
  59. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui.py +0 -0
  60. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  61. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
  62. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  63. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  64. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  65. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/mcp_connecting_view.py +0 -0
  66. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
  67. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  68. {comate_cli-0.5.0 → comate_cli-0.5.1}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  69. {comate_cli-0.5.0 → comate_cli-0.5.1}/docs/superpowers/plans/2026-04-03-phrase-shuffle.md +0 -0
  70. {comate_cli-0.5.0 → comate_cli-0.5.1}/docs/superpowers/specs/2026-04-01-conditional-diff-subtitle-design.md +0 -0
  71. {comate_cli-0.5.0 → comate_cli-0.5.1}/docs/superpowers/specs/2026-04-03-phrase-shuffle-design.md +0 -0
  72. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/conftest.py +0 -0
  73. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_animator_shuffle.py +0 -0
  74. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_app_mcp_preload.py +0 -0
  75. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_app_preflight_gate.py +0 -0
  76. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_app_print_mode.py +0 -0
  77. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_app_shutdown.py +0 -0
  78. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_app_usage_line.py +0 -0
  79. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_cli_project_root.py +0 -0
  80. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_compact_command_semantics.py +0 -0
  81. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_completion_context_activation.py +0 -0
  82. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_completion_status_panel.py +0 -0
  83. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_context_command.py +0 -0
  84. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_custom_slash_commands.py +0 -0
  85. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_discover_tab.py +0 -0
  86. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_errors_tab.py +0 -0
  87. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_format_error.py +0 -0
  88. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_handle_error.py +0 -0
  89. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_history_printer.py +0 -0
  90. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_history_sync.py +0 -0
  91. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_input_behavior.py +0 -0
  92. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_input_history.py +0 -0
  93. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_installed_tab.py +0 -0
  94. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_interrupt_exit_semantics.py +0 -0
  95. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_layout_coordinator.py +0 -0
  96. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_logo.py +0 -0
  97. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_main_args.py +0 -0
  98. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_marketplaces_tab.py +0 -0
  99. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_mcp_cli.py +0 -0
  100. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_mcp_slash_command.py +0 -0
  101. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_mention_completer.py +0 -0
  102. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_path_context_hint.py +0 -0
  103. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_plugin_slash_commands.py +0 -0
  104. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_plugin_tui_components.py +0 -0
  105. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_preflight.py +0 -0
  106. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_preflight_copilot.py +0 -0
  107. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_question_key_bindings.py +0 -0
  108. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_question_view.py +0 -0
  109. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_resume_selector.py +0 -0
  110. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_rewind_command_semantics.py +0 -0
  111. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_rewind_store.py +0 -0
  112. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_rpc_protocol.py +0 -0
  113. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_rpc_stdio_bridge.py +0 -0
  114. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_selection_menu.py +0 -0
  115. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_skills_slash_command.py +0 -0
  116. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_slash_argument_hint.py +0 -0
  117. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_slash_completer.py +0 -0
  118. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_slash_registry.py +0 -0
  119. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_status_bar.py +0 -0
  120. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_status_bar_transient.py +0 -0
  121. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_task_panel_format.py +0 -0
  122. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_task_panel_key_bindings.py +0 -0
  123. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_task_panel_rendering.py +0 -0
  124. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_task_poll.py +0 -0
  125. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_tool_view.py +0 -0
  126. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_tui_elapsed_status.py +0 -0
  127. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_tui_mcp_init_gate.py +0 -0
  128. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_tui_paste_placeholder.py +0 -0
  129. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_tui_split_invariance.py +0 -0
  130. {comate_cli-0.5.0 → comate_cli-0.5.1}/tests/test_update_check.py +0 -0
  131. {comate_cli-0.5.0 → comate_cli-0.5.1}/uv.lock +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.1
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
@@ -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:
@@ -700,18 +741,18 @@ class EventRenderer:
700
741
  text_obj.append(f" · {model_name}", style="dim")
701
742
  text_obj.append("\n")
702
743
  text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
703
- self._history.append(
744
+ self._append_history_entry(
704
745
  HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
705
746
  )
706
747
  return
707
748
  if model_name:
708
749
  text_obj = Text(signature)
709
750
  text_obj.append(f" · {model_name}", style="dim")
710
- self._history.append(
751
+ self._append_history_entry(
711
752
  HistoryEntry(entry_type="tool_result", text=text_obj, severity=sev, subtitle=subtitle)
712
753
  )
713
754
  return
714
- self._history.append(
755
+ self._append_history_entry(
715
756
  HistoryEntry(
716
757
  entry_type="tool_result",
717
758
  text=signature,
@@ -764,7 +805,9 @@ class EventRenderer:
764
805
  if state is None:
765
806
  display_name = resolve_display_tool_name(tool_name, {})
766
807
  signature = _tool_signature(display_name, "")
767
- self._history.append(HistoryEntry(entry_type="tool_result", text=signature, severity=sev, subtitle=subtitle))
808
+ self._append_history_entry(
809
+ HistoryEntry(entry_type="tool_result", text=signature, severity=sev, subtitle=subtitle)
810
+ )
768
811
  return
769
812
 
770
813
  if state.is_task:
@@ -811,15 +854,19 @@ class EventRenderer:
811
854
  text_obj = Text(base)
812
855
  text_obj.append("\n")
813
856
  text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
814
- self._history.append(
857
+ self._append_history_entry(
815
858
  HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
816
859
  )
817
860
  return
818
861
 
819
862
  if isinstance(base, Text):
820
- self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle))
863
+ self._append_history_entry(
864
+ HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle)
865
+ )
821
866
  else:
822
- self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle))
867
+ self._append_history_entry(
868
+ HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle)
869
+ )
823
870
 
824
871
  @staticmethod
825
872
  def _render_diff_text(diff_lines: list[str], max_lines: int = 50) -> Text:
@@ -900,7 +947,7 @@ class EventRenderer:
900
947
  def _append_questions(self, questions: list[dict[str, Any]]) -> None:
901
948
  if not questions:
902
949
  return
903
- self._history.append(
950
+ self._append_history_entry(
904
951
  HistoryEntry(
905
952
  entry_type="tool_result",
906
953
  text=f"Input required: {len(questions)} question(s) pending.",
@@ -919,7 +966,7 @@ class EventRenderer:
919
966
  if label:
920
967
  labels.append(label)
921
968
  choice_preview = f" (Options: {' / '.join(labels)})" if labels else ""
922
- self._history.append(
969
+ self._append_history_entry(
923
970
  HistoryEntry(
924
971
  entry_type="tool_result",
925
972
  text=f" {idx}. {header}: {question_text} {choice_preview}".strip(),
@@ -962,7 +1009,7 @@ class EventRenderer:
962
1009
  if started is not None:
963
1010
  elapsed_suffix = f" · {_format_duration(time.monotonic() - started)}"
964
1011
  total = len(normalized)
965
- self._history.append(
1012
+ self._append_history_entry(
966
1013
  HistoryEntry(
967
1014
  entry_type="tool_result",
968
1015
  text=f"tasks {total}/{total} completed{elapsed_suffix}",
@@ -1042,7 +1089,7 @@ class EventRenderer:
1042
1089
  pass
1043
1090
  case ThinkingEvent(content=thinking):
1044
1091
  self._thinking_content = thinking
1045
- self._history.append(HistoryEntry(entry_type="thinking", text=thinking))
1092
+ self._append_history_entry(HistoryEntry(entry_type="thinking", text=thinking))
1046
1093
  case CompactionResultEvent(
1047
1094
  current_tokens=tokens,
1048
1095
  threshold=threshold,
@@ -1207,7 +1254,7 @@ class EventRenderer:
1207
1254
  if reason == "waiting_for_plan_approval":
1208
1255
  return (False, None)
1209
1256
  if reason == "interrupted":
1210
- self._history.append(
1257
+ self._append_history_entry(
1211
1258
  HistoryEntry(entry_type="system", text="Current task interrupted.", severity="warning")
1212
1259
  )
1213
1260
  case PlanApprovalRequiredEvent(
@@ -1225,18 +1272,18 @@ class EventRenderer:
1225
1272
  except Exception:
1226
1273
  logger.warning("ExitPlanMode: Failed to read plan file %s", plan_path)
1227
1274
  if plan_content:
1228
- self._history.append(
1275
+ self._append_history_entry(
1229
1276
  HistoryEntry(
1230
1277
  entry_type="system",
1231
1278
  text="─── Here is the plan, please review and approve or reject ───",
1232
1279
  )
1233
1280
  )
1234
- self._history.append(HistoryEntry(entry_type="assistant", text=plan_content))
1281
+ self._append_history_entry(HistoryEntry(entry_type="assistant", text=plan_content))
1235
1282
 
1236
1283
  text = f"Plan ready for review: {plan_path}"
1237
1284
  if summary:
1238
1285
  text = f"{text} | {summary}"
1239
- self._history.append(
1286
+ self._append_history_entry(
1240
1287
  HistoryEntry(entry_type="system", text=text)
1241
1288
  )
1242
1289
  case TeamMessageEvent(
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
  import logging
5
5
  import os
6
6
  import sys
7
+ import threading
8
+ import time
7
9
  from typing import TYPE_CHECKING
8
10
 
9
11
  from prompt_toolkit.application import run_in_terminal
@@ -12,6 +14,30 @@ if TYPE_CHECKING:
12
14
  from comate_cli.terminal_agent.event_renderer import EventRenderer
13
15
 
14
16
 
17
+ _COMATE_LOGGING_SESSION_MARKER = "_comate_tui_logging_session"
18
+ _RECENT_DUPLICATE_LOG_WINDOW_SECONDS = 0.5
19
+ _recent_log_message_times: dict[str, float] = {}
20
+ _recent_log_message_lock = threading.Lock()
21
+ _active_tui_logging_session: "TUILoggingSession | None" = None
22
+ _active_tui_logging_session_lock = threading.Lock()
23
+
24
+
25
+ def _should_drop_recent_duplicate_log(msg_key: str) -> bool:
26
+ now = time.monotonic()
27
+ with _recent_log_message_lock:
28
+ expired_keys = [
29
+ key
30
+ for key, ts in _recent_log_message_times.items()
31
+ if now - ts > _RECENT_DUPLICATE_LOG_WINDOW_SECONDS
32
+ ]
33
+ for key in expired_keys:
34
+ _recent_log_message_times.pop(key, None)
35
+
36
+ previous = _recent_log_message_times.get(msg_key)
37
+ _recent_log_message_times[msg_key] = now
38
+ return previous is not None
39
+
40
+
15
41
  class TUILoggingHandler(logging.Handler):
16
42
  """自定义 logging handler,将日志友好地显示在 TUI 中
17
43
 
@@ -40,6 +66,8 @@ class TUILoggingHandler(logging.Handler):
40
66
 
41
67
  # 首次显示检查
42
68
  msg_key = self._get_message_key(record)
69
+ if _should_drop_recent_duplicate_log(msg_key):
70
+ return
43
71
  if msg_key in self._shown_messages:
44
72
  return
45
73
  self._shown_messages.add(msg_key)
@@ -108,9 +136,9 @@ class TUILoggingHandler(logging.Handler):
108
136
  # DummyApplication,直接调用
109
137
  _append()
110
138
  else:
111
- app.create_background_task(
112
- run_in_terminal(_append, in_executor=False)
113
- )
139
+ # run_in_terminal() 自身已经会调度 Future;这里不能再交给
140
+ # create_background_task() 二次包装,否则会触发重复执行竞态。
141
+ run_in_terminal(_append, in_executor=False)
114
142
  except Exception:
115
143
  # 没有 app 或导入失败,直接调用
116
144
  _append()
@@ -159,6 +187,10 @@ class TUILoggingSession:
159
187
  continue
160
188
 
161
189
  self._root_logger.setLevel(self._previous_level)
190
+ with _active_tui_logging_session_lock:
191
+ global _active_tui_logging_session
192
+ if _active_tui_logging_session is self:
193
+ _active_tui_logging_session = None
162
194
 
163
195
 
164
196
  def _build_file_handler() -> logging.Handler:
@@ -197,14 +229,40 @@ def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
197
229
  root = logging.getLogger()
198
230
  previous_level = root.level
199
231
 
232
+ with _active_tui_logging_session_lock:
233
+ global _active_tui_logging_session
234
+ active_session = _active_tui_logging_session
235
+ if active_session is not None:
236
+ active_session.close()
237
+ with _recent_log_message_lock:
238
+ _recent_log_message_times.clear()
239
+
240
+ stale_handlers: list[logging.Handler] = []
241
+ for handler in list(root.handlers):
242
+ if not getattr(handler, _COMATE_LOGGING_SESSION_MARKER, False):
243
+ continue
244
+ try:
245
+ root.removeHandler(handler)
246
+ except Exception:
247
+ pass
248
+ stale_handlers.append(handler)
249
+
250
+ for handler in stale_handlers:
251
+ try:
252
+ handler.close()
253
+ except Exception:
254
+ continue
255
+
200
256
  # 1. 日志文件 handler(完整调试信息,含 traceback)
201
257
  file_handler = _build_file_handler()
258
+ setattr(file_handler, _COMATE_LOGGING_SESSION_MARKER, True)
202
259
  root.addHandler(file_handler)
203
260
  root.setLevel(logging.DEBUG)
204
261
 
205
262
  # 2. TUI handler 挂到 root(覆盖所有命名空间的 WARNING/ERROR)
206
263
  tui_handler = TUILoggingHandler(renderer)
207
264
  tui_handler.setLevel(logging.WARNING)
265
+ setattr(tui_handler, _COMATE_LOGGING_SESSION_MARKER, True)
208
266
  root.addHandler(tui_handler)
209
267
 
210
268
  # 3. 临时静默直写终端的 handler,避免污染 prompt_toolkit UI
@@ -218,9 +276,12 @@ def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
218
276
  handler.addFilter(log_filter)
219
277
  muted_handlers.append((handler, log_filter))
220
278
 
221
- return TUILoggingSession(
279
+ session = TUILoggingSession(
222
280
  root_logger=root,
223
281
  added_handlers=[file_handler, tui_handler],
224
282
  muted_handlers=muted_handlers,
225
283
  previous_level=previous_level,
226
284
  )
285
+ with _active_tui_logging_session_lock:
286
+ _active_tui_logging_session = session
287
+ return session
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comate-cli"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "Comate terminal CLI built on comate-agent-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import tempfile
4
4
  import unittest
5
5
  from pathlib import Path
6
+ from unittest.mock import patch
6
7
 
7
8
  from comate_agent_sdk.agent.events import (
8
9
  CompactionResultEvent,
@@ -36,6 +37,41 @@ def _task_payload(size: int) -> list[dict[str, str]]:
36
37
 
37
38
 
38
39
  class TestEventRenderer(unittest.TestCase):
40
+ def test_append_system_message_deduplicates_adjacent_warning_within_window(self) -> None:
41
+ renderer = EventRenderer()
42
+
43
+ with patch(
44
+ "comate_cli.terminal_agent.event_renderer.time.monotonic",
45
+ side_effect=[100.0, 100.2, 100.2],
46
+ ):
47
+ renderer.append_system_message("boom", severity="warning")
48
+ renderer.append_system_message("boom", severity="warning")
49
+
50
+ system_entries = [e for e in renderer.history_entries() if e.entry_type == "system"]
51
+ self.assertEqual(len(system_entries), 1)
52
+ self.assertEqual(system_entries[0].text, "boom")
53
+
54
+ def test_append_system_message_keeps_same_warning_after_window(self) -> None:
55
+ renderer = EventRenderer()
56
+
57
+ with patch(
58
+ "comate_cli.terminal_agent.event_renderer.time.monotonic",
59
+ side_effect=[100.0, 100.7, 100.7],
60
+ ):
61
+ renderer.append_system_message("boom", severity="warning")
62
+ renderer.append_system_message("boom", severity="warning")
63
+
64
+ system_entries = [e for e in renderer.history_entries() if e.entry_type == "system"]
65
+ self.assertEqual(len(system_entries), 2)
66
+
67
+ def test_append_system_message_does_not_deduplicate_info(self) -> None:
68
+ renderer = EventRenderer()
69
+ renderer.append_system_message("same-info", severity="info")
70
+ renderer.append_system_message("same-info", severity="info")
71
+
72
+ system_entries = [e for e in renderer.history_entries() if e.entry_type == "system"]
73
+ self.assertEqual(len(system_entries), 2)
74
+
39
75
  def test_team_message_event_deduplicates_identical_event(self) -> None:
40
76
  renderer = EventRenderer()
41
77
  event = TeamMessageEvent(
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import logging
5
+ import sys
6
+ from unittest.mock import patch
7
+
8
+ from comate_cli.terminal_agent.logging_adapter import (
9
+ TUILoggingHandler,
10
+ TUILoggingSession,
11
+ setup_tui_logging,
12
+ )
13
+
14
+
15
+ class _FakeRenderer:
16
+ def __init__(self) -> None:
17
+ self.messages: list[tuple[str, str]] = []
18
+
19
+ def append_system_message(self, message: str, *, severity: str) -> None:
20
+ self.messages.append((message, severity))
21
+
22
+
23
+ def test_setup_tui_logging_preserves_existing_root_handlers_and_restores_them() -> None:
24
+ root = logging.getLogger()
25
+ original_handlers = list(root.handlers)
26
+ original_level = root.level
27
+ stderr_handler = logging.StreamHandler(sys.stderr)
28
+ root.addHandler(stderr_handler)
29
+
30
+ session: TUILoggingSession | None = None
31
+ try:
32
+ session = setup_tui_logging(_FakeRenderer())
33
+
34
+ assert stderr_handler in root.handlers
35
+ assert any(isinstance(handler, TUILoggingHandler) for handler in root.handlers)
36
+ assert root.level == logging.DEBUG
37
+ assert len(stderr_handler.filters) == 1
38
+ finally:
39
+ if session is not None:
40
+ session.close()
41
+ if stderr_handler in root.handlers:
42
+ root.removeHandler(stderr_handler)
43
+ root.handlers[:] = original_handlers
44
+ root.setLevel(original_level)
45
+
46
+ assert stderr_handler.filters == []
47
+ assert root.handlers == original_handlers
48
+ assert root.level == original_level
49
+
50
+
51
+ def test_setup_tui_logging_does_not_mute_non_terminal_stream_handlers() -> None:
52
+ root = logging.getLogger()
53
+ original_handlers = list(root.handlers)
54
+ original_level = root.level
55
+ buffer_handler = logging.StreamHandler(io.StringIO())
56
+ root.addHandler(buffer_handler)
57
+
58
+ session: TUILoggingSession | None = None
59
+ try:
60
+ session = setup_tui_logging(_FakeRenderer())
61
+ assert buffer_handler in root.handlers
62
+ assert buffer_handler.filters == []
63
+ finally:
64
+ if session is not None:
65
+ session.close()
66
+ if buffer_handler in root.handlers:
67
+ root.removeHandler(buffer_handler)
68
+ root.handlers[:] = original_handlers
69
+ root.setLevel(original_level)
70
+
71
+
72
+ def test_setup_tui_logging_replaces_stale_managed_handlers_before_installing_new_ones() -> None:
73
+ root = logging.getLogger()
74
+ original_handlers = list(root.handlers)
75
+ original_level = root.level
76
+ renderer1 = _FakeRenderer()
77
+ renderer2 = _FakeRenderer()
78
+
79
+ session1: TUILoggingSession | None = None
80
+ session2: TUILoggingSession | None = None
81
+ try:
82
+ session1 = setup_tui_logging(renderer1)
83
+ session2 = setup_tui_logging(renderer2)
84
+
85
+ tui_handlers = [h for h in root.handlers if isinstance(h, TUILoggingHandler)]
86
+ managed_handlers = [
87
+ h for h in root.handlers if getattr(h, "_comate_tui_logging_session", False)
88
+ ]
89
+
90
+ assert len(tui_handlers) == 1
91
+ assert len(managed_handlers) == 2
92
+
93
+ logging.getLogger("test.logging").warning("duplicate-warning-check")
94
+
95
+ assert renderer1.messages == []
96
+ assert renderer2.messages == [("duplicate-warning-check", "warning")]
97
+ finally:
98
+ if session2 is not None:
99
+ session2.close()
100
+ if session1 is not None:
101
+ session1.close()
102
+ root.handlers[:] = original_handlers
103
+ root.setLevel(original_level)
104
+
105
+
106
+ def test_tui_logging_handler_deduplicates_recent_duplicate_across_handlers() -> None:
107
+ renderer = _FakeRenderer()
108
+ handler1 = TUILoggingHandler(renderer)
109
+ handler2 = TUILoggingHandler(renderer)
110
+ record = logging.LogRecord(
111
+ name="test.logging",
112
+ level=logging.WARNING,
113
+ pathname=__file__,
114
+ lineno=1,
115
+ msg="duplicate-warning-check",
116
+ args=(),
117
+ exc_info=None,
118
+ )
119
+
120
+ with patch(
121
+ "comate_cli.terminal_agent.logging_adapter._recent_log_message_times",
122
+ {},
123
+ ):
124
+ handler1.emit(record)
125
+ handler2.emit(record)
126
+
127
+ assert renderer.messages == [("duplicate-warning-check", "warning")]
128
+
129
+
130
+ def test_tui_logging_handler_does_not_wrap_run_in_terminal_with_background_task() -> None:
131
+ renderer = _FakeRenderer()
132
+ handler = TUILoggingHandler(renderer)
133
+ record = logging.LogRecord(
134
+ name="test.logging",
135
+ level=logging.WARNING,
136
+ pathname=__file__,
137
+ lineno=1,
138
+ msg="background-task-race-check",
139
+ args=(),
140
+ exc_info=None,
141
+ )
142
+
143
+ class _FakeApp:
144
+ def create_background_task(self, _coroutine) -> None:
145
+ raise AssertionError("create_background_task should not be called")
146
+
147
+ def _run_in_terminal_now(func, render_cli_done=False, in_executor=False): # type: ignore[no-untyped-def]
148
+ assert render_cli_done is False
149
+ assert in_executor is False
150
+ func()
151
+ return object()
152
+
153
+ with (
154
+ patch("prompt_toolkit.application.get_app", return_value=_FakeApp()),
155
+ patch(
156
+ "comate_cli.terminal_agent.logging_adapter.run_in_terminal",
157
+ side_effect=_run_in_terminal_now,
158
+ ),
159
+ patch(
160
+ "comate_cli.terminal_agent.logging_adapter._recent_log_message_times",
161
+ {},
162
+ ),
163
+ ):
164
+ handler.emit(record)
165
+
166
+ assert renderer.messages == [("background-task-race-check", "warning")]
@@ -1,68 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import io
4
- import logging
5
- import sys
6
-
7
- from comate_cli.terminal_agent.logging_adapter import (
8
- TUILoggingHandler,
9
- TUILoggingSession,
10
- setup_tui_logging,
11
- )
12
-
13
-
14
- class _FakeRenderer:
15
- def __init__(self) -> None:
16
- self.messages: list[tuple[str, str]] = []
17
-
18
- def append_system_message(self, message: str, *, severity: str) -> None:
19
- self.messages.append((message, severity))
20
-
21
-
22
- def test_setup_tui_logging_preserves_existing_root_handlers_and_restores_them() -> None:
23
- root = logging.getLogger()
24
- original_handlers = list(root.handlers)
25
- original_level = root.level
26
- stderr_handler = logging.StreamHandler(sys.stderr)
27
- root.addHandler(stderr_handler)
28
-
29
- session: TUILoggingSession | None = None
30
- try:
31
- session = setup_tui_logging(_FakeRenderer())
32
-
33
- assert stderr_handler in root.handlers
34
- assert any(isinstance(handler, TUILoggingHandler) for handler in root.handlers)
35
- assert root.level == logging.DEBUG
36
- assert len(stderr_handler.filters) == 1
37
- finally:
38
- if session is not None:
39
- session.close()
40
- if stderr_handler in root.handlers:
41
- root.removeHandler(stderr_handler)
42
- root.handlers[:] = original_handlers
43
- root.setLevel(original_level)
44
-
45
- assert stderr_handler.filters == []
46
- assert root.handlers == original_handlers
47
- assert root.level == original_level
48
-
49
-
50
- def test_setup_tui_logging_does_not_mute_non_terminal_stream_handlers() -> None:
51
- root = logging.getLogger()
52
- original_handlers = list(root.handlers)
53
- original_level = root.level
54
- buffer_handler = logging.StreamHandler(io.StringIO())
55
- root.addHandler(buffer_handler)
56
-
57
- session: TUILoggingSession | None = None
58
- try:
59
- session = setup_tui_logging(_FakeRenderer())
60
- assert buffer_handler in root.handlers
61
- assert buffer_handler.filters == []
62
- finally:
63
- if session is not None:
64
- session.close()
65
- if buffer_handler in root.handlers:
66
- root.removeHandler(buffer_handler)
67
- root.handlers[:] = original_handlers
68
- root.setLevel(original_level)
File without changes
File without changes
File without changes
File without changes