comate-cli 0.5.1__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.1 → comate_cli-0.5.2}/PKG-INFO +2 -1
  2. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/mcp_cli.py +20 -4
  3. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/app.py +1 -1
  4. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/event_renderer.py +36 -4
  5. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/logging_adapter.py +91 -8
  6. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/rewind_store.py +3 -1
  7. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tool_view.py +40 -17
  8. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui.py +22 -7
  9. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/commands.py +41 -10
  10. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/history_sync.py +10 -2
  11. {comate_cli-0.5.1 → 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.1 → comate_cli-0.5.2}/pyproject.toml +6 -1
  14. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_event_renderer.py +39 -10
  15. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_history_sync.py +107 -0
  16. {comate_cli-0.5.1 → 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.1 → comate_cli-0.5.2}/tests/test_mcp_slash_command.py +111 -0
  19. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_rewind_store.py +125 -11
  20. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_tool_view.py +4 -4
  21. {comate_cli-0.5.1 → 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.1/tests/test_logging_adapter.py +0 -166
  24. {comate_cli-0.5.1 → comate_cli-0.5.2}/.gitignore +0 -0
  25. {comate_cli-0.5.1 → comate_cli-0.5.2}/README.md +0 -0
  26. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/__init__.py +0 -0
  27. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/__main__.py +0 -0
  28. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/main.py +0 -0
  29. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/__init__.py +0 -0
  30. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/animations.py +0 -0
  31. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/assistant_render.py +0 -0
  32. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/codenames.py +0 -0
  33. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  34. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/env_utils.py +0 -0
  35. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/error_display.py +0 -0
  36. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  37. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/history_printer.py +0 -0
  38. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/input_geometry.py +0 -0
  39. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  40. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/logo.py +0 -0
  41. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/markdown_render.py +0 -0
  42. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/mention_completer.py +0 -0
  43. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/message_style.py +0 -0
  44. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/models.py +0 -0
  45. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/path_context_hint.py +0 -0
  46. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/__init__.py +0 -0
  47. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/__init__.py +0 -0
  48. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/detail_view.py +0 -0
  49. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/plugin_list.py +0 -0
  50. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/search_box.py +0 -0
  51. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/components/tab_bar.py +0 -0
  52. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/marketplace_install_view.py +0 -0
  53. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/plugin_picker.py +0 -0
  54. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/__init__.py +0 -0
  55. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/discover_tab.py +0 -0
  56. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/errors_tab.py +0 -0
  57. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/installed_tab.py +0 -0
  58. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/plugins/tabs/marketplaces_tab.py +0 -0
  59. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/preflight.py +0 -0
  60. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/question_view.py +0 -0
  61. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/resume_selector.py +0 -0
  62. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  63. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  64. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/selection_menu.py +0 -0
  65. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/session_view.py +0 -0
  66. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/slash_commands.py +0 -0
  67. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/startup.py +0 -0
  68. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/status_bar.py +0 -0
  69. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/text_effects.py +0 -0
  70. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tips.py +0 -0
  71. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  72. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  73. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  74. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
  75. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  76. {comate_cli-0.5.1 → comate_cli-0.5.2}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  77. {comate_cli-0.5.1 → comate_cli-0.5.2}/docs/superpowers/plans/2026-04-03-phrase-shuffle.md +0 -0
  78. {comate_cli-0.5.1 → comate_cli-0.5.2}/docs/superpowers/specs/2026-04-01-conditional-diff-subtitle-design.md +0 -0
  79. {comate_cli-0.5.1 → comate_cli-0.5.2}/docs/superpowers/specs/2026-04-03-phrase-shuffle-design.md +0 -0
  80. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/conftest.py +0 -0
  81. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_animator_shuffle.py +0 -0
  82. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_app_mcp_preload.py +0 -0
  83. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_app_preflight_gate.py +0 -0
  84. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_app_print_mode.py +0 -0
  85. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_app_shutdown.py +0 -0
  86. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_app_usage_line.py +0 -0
  87. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_cli_project_root.py +0 -0
  88. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_compact_command_semantics.py +0 -0
  89. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_completion_context_activation.py +0 -0
  90. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_completion_status_panel.py +0 -0
  91. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_context_command.py +0 -0
  92. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_custom_slash_commands.py +0 -0
  93. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_discover_tab.py +0 -0
  94. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_errors_tab.py +0 -0
  95. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_format_error.py +0 -0
  96. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_handle_error.py +0 -0
  97. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_history_printer.py +0 -0
  98. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_input_behavior.py +0 -0
  99. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_installed_tab.py +0 -0
  100. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_interrupt_exit_semantics.py +0 -0
  101. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_layout_coordinator.py +0 -0
  102. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_logo.py +0 -0
  103. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_main_args.py +0 -0
  104. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_marketplaces_tab.py +0 -0
  105. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_mcp_cli.py +0 -0
  106. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_mention_completer.py +0 -0
  107. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_path_context_hint.py +0 -0
  108. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_plugin_slash_commands.py +0 -0
  109. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_plugin_tui_components.py +0 -0
  110. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_preflight.py +0 -0
  111. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_preflight_copilot.py +0 -0
  112. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_question_key_bindings.py +0 -0
  113. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_question_view.py +0 -0
  114. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_resume_selector.py +0 -0
  115. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_rewind_command_semantics.py +0 -0
  116. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_rpc_protocol.py +0 -0
  117. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_rpc_stdio_bridge.py +0 -0
  118. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_selection_menu.py +0 -0
  119. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_skills_slash_command.py +0 -0
  120. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_slash_argument_hint.py +0 -0
  121. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_slash_completer.py +0 -0
  122. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_slash_registry.py +0 -0
  123. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_status_bar.py +0 -0
  124. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_status_bar_transient.py +0 -0
  125. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_task_panel_format.py +0 -0
  126. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_task_panel_key_bindings.py +0 -0
  127. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_task_panel_rendering.py +0 -0
  128. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_task_poll.py +0 -0
  129. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_tui_elapsed_status.py +0 -0
  130. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_tui_mcp_init_gate.py +0 -0
  131. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_tui_paste_placeholder.py +0 -0
  132. {comate_cli-0.5.1 → comate_cli-0.5.2}/tests/test_tui_split_invariance.py +0 -0
  133. {comate_cli-0.5.1 → 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.1
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
 
@@ -712,7 +712,39 @@ class EventRenderer:
712
712
 
713
713
  if lowered == "grep":
714
714
  lines = [line for line in (result or "").splitlines() if line.strip()]
715
- 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"
716
748
 
717
749
  return None
718
750
 
@@ -729,7 +761,7 @@ class EventRenderer:
729
761
  Args:
730
762
  signature: 工具签名,例如 "Read(path=xxx)"
731
763
  is_error: 是否为错误结果
732
- diff_lines: optional diff lines for Edit/MultiEdit
764
+ diff_lines: optional diff lines for Edit
733
765
  model_name: model name for Agent tools (rendered dim)
734
766
  subtitle: optional subtitle rendered as `⎿ ...`
735
767
  """
@@ -833,7 +865,7 @@ class EventRenderer:
833
865
  else:
834
866
  display_name = state.display_tool_name or state.tool_name
835
867
  summary = state.args_summary
836
- # Append line range for Edit/MultiEdit
868
+ # Append line range for Edit
837
869
  if display_name == "Update" and metadata:
838
870
  sl = metadata.get("start_line")
839
871
  el = metadata.get("end_line")
@@ -843,7 +875,7 @@ class EventRenderer:
843
875
  signature = _tool_signature(display_name, summary)
844
876
  base = f"{signature}"
845
877
 
846
- # Render diff for Edit/MultiEdit if metadata contains diff lines
878
+ # Render diff for Edit if metadata contains diff lines
847
879
  if not is_error and metadata:
848
880
  diff_lines = metadata.get("diff")
849
881
  if isinstance(diff_lines, list) and len(diff_lines) > 0:
@@ -6,6 +6,7 @@ import os
6
6
  import sys
7
7
  import threading
8
8
  import time
9
+ from pathlib import Path
9
10
  from typing import TYPE_CHECKING
10
11
 
11
12
  from prompt_toolkit.application import run_in_terminal
@@ -14,6 +15,8 @@ if TYPE_CHECKING:
14
15
  from comate_cli.terminal_agent.event_renderer import EventRenderer
15
16
 
16
17
 
18
+ logger = logging.getLogger(__name__)
19
+
17
20
  _COMATE_LOGGING_SESSION_MARKER = "_comate_tui_logging_session"
18
21
  _RECENT_DUPLICATE_LOG_WINDOW_SECONDS = 0.5
19
22
  _recent_log_message_times: dict[str, float] = {}
@@ -21,6 +24,11 @@ _recent_log_message_lock = threading.Lock()
21
24
  _active_tui_logging_session: "TUILoggingSession | None" = None
22
25
  _active_tui_logging_session_lock = threading.Lock()
23
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
+
24
32
 
25
33
  def _should_drop_recent_duplicate_log(msg_key: str) -> bool:
26
34
  now = time.monotonic()
@@ -193,19 +201,80 @@ class TUILoggingSession:
193
201
  _active_tui_logging_session = None
194
202
 
195
203
 
196
- def _build_file_handler() -> logging.Handler:
197
- from logging.handlers import RotatingFileHandler
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
+
233
+
234
+ def _resolve_log_level(project_root: Path | None = None) -> int:
235
+ """解析生效日志级别。
236
+
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
198
267
 
199
268
  log_dir = os.path.join(os.path.expanduser("~"), ".comate", "logs")
200
269
  os.makedirs(log_dir, exist_ok=True)
201
270
  log_path = os.path.join(log_dir, "agent.log")
202
- file_handler = RotatingFileHandler(
271
+ file_handler = ConcurrentRotatingFileHandler(
203
272
  log_path,
204
273
  maxBytes=10 * 1024 * 1024,
205
274
  backupCount=3,
206
275
  encoding="utf-8",
207
276
  )
208
- file_handler.setLevel(logging.DEBUG)
277
+ file_handler.setLevel(level)
209
278
  file_handler.setFormatter(
210
279
  logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
211
280
  )
@@ -219,15 +288,26 @@ def _should_mute_terminal_stream(handler: logging.Handler) -> bool:
219
288
  return stream in {sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__}
220
289
 
221
290
 
222
- def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
291
+ def setup_tui_logging(
292
+ renderer: EventRenderer,
293
+ *,
294
+ project_root: Path | None = None,
295
+ ) -> TUILoggingSession:
223
296
  """统一日志初始化:文件 + TUI 双通道。
224
297
 
225
- - 所有日志(含 traceback)写入 ~/.comate/logs/agent.log(RotatingFileHandler)
298
+ - 所有日志(含 traceback)写入 ~/.comate/logs/agent.log
299
+ (ConcurrentRotatingFileHandler:跨进程文件锁 + Windows rename 重试)
226
300
  - WARNING/ERROR 以用户友好格式显示在 TUI scrollback
227
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 刷屏。
228
307
  """
229
308
  root = logging.getLogger()
230
309
  previous_level = root.level
310
+ level = _resolve_log_level(project_root)
231
311
 
232
312
  with _active_tui_logging_session_lock:
233
313
  global _active_tui_logging_session
@@ -254,10 +334,13 @@ def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
254
334
  continue
255
335
 
256
336
  # 1. 日志文件 handler(完整调试信息,含 traceback)
257
- file_handler = _build_file_handler()
337
+ file_handler = _build_file_handler(level)
258
338
  setattr(file_handler, _COMATE_LOGGING_SESSION_MARKER, True)
259
339
  root.addHandler(file_handler)
260
- 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)
261
344
 
262
345
  # 2. TUI handler 挂到 root(覆盖所有命名空间的 WARNING/ERROR)
263
346
  tui_handler = TUILoggingHandler(renderer)
@@ -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
@@ -63,7 +63,7 @@ _ALLOWED_PRIORITY = {"high", "medium", "low"}
63
63
  # Tools that are always hidden from scrollback (have dedicated event channels).
64
64
  _ALWAYS_HIDDEN_TOOLS: frozenset[str] = frozenset({"askuserquestion", "exitplanmode"})
65
65
 
66
- # Task coordination tools hidden from scrollback unless erroring.
66
+ # Task coordination tools hidden from scrollback unless explicitly surfaced.
67
67
  _SILENT_TASK_TOOLS: frozenset[str] = frozenset({
68
68
  "taskcreate", "taskupdate", "tasklist", "taskget",
69
69
  })
@@ -149,13 +149,13 @@ def extract_todos(args: dict[str, Any]) -> list[TodoItemState] | None:
149
149
  return todos
150
150
 
151
151
 
152
- def _truncate(content: str, max_len: int = 280) -> str:
152
+ def _truncate(content: str, max_len: int = 400) -> str:
153
153
  if len(content) <= max_len:
154
154
  return content
155
155
  return f"{content[:max_len - 3]}..."
156
156
 
157
157
 
158
- TOOL_SUMMARY_MAX_LENGTH = 50
158
+ TOOL_SUMMARY_MAX_LENGTH = 160
159
159
 
160
160
 
161
161
  def _truncate_path_middle(path: str | None, max_len: int) -> str:
@@ -206,12 +206,22 @@ def _compact_json(value: Any, max_len: int = 220) -> str:
206
206
  return _truncate(content, max_len=max_len)
207
207
 
208
208
 
209
+ def _summarize_env_vars(value: Any, max_len: int = 120) -> str:
210
+ if not isinstance(value, dict) or not value:
211
+ return ""
212
+ parts: list[str] = []
213
+ for key in sorted(value):
214
+ rendered = f"{key}={value[key]}"
215
+ parts.append(_truncate(str(rendered), 48))
216
+ return _truncate(",".join(parts), max_len=max_len)
217
+
218
+
209
219
  def _friendly_kv(args: dict[str, Any], max_len: int = 220) -> str:
210
220
  """将 dict 格式化为 key: "value" 的友好显示格式。"""
211
221
  parts: list[str] = []
212
222
  for k, v in args.items():
213
223
  if isinstance(v, str):
214
- parts.append(f'{k}: "{_truncate(v, 80)}"')
224
+ parts.append(f'{k}: "{_truncate(v, 160)}"')
215
225
  else:
216
226
  parts.append(f"{k}: {v}")
217
227
  result = ", ".join(parts)
@@ -363,30 +373,30 @@ def summarize_tool_args(
363
373
  if lowered == "write":
364
374
  path = _lookup_arg(args, "file_path", "path")
365
375
  path_display = _truncate_path_middle(
366
- _normalize_path_for_display(path, project_root) or "", 45
376
+ _normalize_path_for_display(path, project_root) or "", 120
367
377
  )
368
378
  return _truncate(f"path={path_display}" if path_display else _compact_json(args), TOOL_SUMMARY_MAX_LENGTH)
369
379
  if lowered == "edit":
370
380
  path = _lookup_arg(args, "file_path", "path")
371
381
  path_display = _truncate_path_middle(
372
- _normalize_path_for_display(path, project_root) or "", 45
382
+ _normalize_path_for_display(path, project_root) or "", 120
373
383
  )
374
384
  return _truncate(path_display or _compact_json(args), TOOL_SUMMARY_MAX_LENGTH)
375
385
  if lowered == "multiedit":
376
386
  path = _lookup_arg(args, "file_path", "path")
377
387
  path_display = _truncate_path_middle(
378
- _normalize_path_for_display(path, project_root) or "", 45
388
+ _normalize_path_for_display(path, project_root) or "", 120
379
389
  )
380
390
  return _truncate(path_display or _compact_json(args), TOOL_SUMMARY_MAX_LENGTH)
381
391
  if lowered == "read":
382
392
  path = _lookup_arg(args, "file_path", "path")
383
393
  path_display = _truncate_path_middle(
384
- _normalize_path_for_display(path, project_root) or "", 40
394
+ _normalize_path_for_display(path, project_root) or "", 120
385
395
  )
386
- offset = _lookup_arg(args, "offset_line")
387
- limit = _lookup_arg(args, "limit_lines")
396
+ offset = _lookup_arg(args, "offset", "offset_line")
397
+ limit = _lookup_arg(args, "limit", "limit_lines")
388
398
  raw = f"path={path_display} offset={offset} limit={limit}" if path_display else _compact_json(args)
389
- return _truncate(raw, 65)
399
+ return _truncate(raw, 160)
390
400
  if lowered == "agent":
391
401
  subagent_name, description = _extract_task_identity(args)
392
402
  if description and description != subagent_name:
@@ -421,9 +431,9 @@ def summarize_tool_args(
421
431
  pattern = _lookup_arg(args, "pattern")
422
432
  path = _lookup_arg(args, "path")
423
433
  path_display = _truncate_path_middle(
424
- _normalize_path_for_display(path, project_root) or "", 25
434
+ _normalize_path_for_display(path, project_root) or "", 80
425
435
  )
426
- pattern_display = _truncate(str(pattern), 20) if pattern else ""
436
+ pattern_display = _truncate(str(pattern), 60) if pattern else ""
427
437
  parts = []
428
438
  if path_display:
429
439
  parts.append(f"path={path_display}")
@@ -432,21 +442,34 @@ def summarize_tool_args(
432
442
  return _truncate(" ".join(parts) if parts else _compact_json(args), TOOL_SUMMARY_MAX_LENGTH)
433
443
  if lowered == "bash":
434
444
  command_args = _lookup_arg(args, "args")
445
+ env_summary = _summarize_env_vars(_lookup_arg(args, "env"))
435
446
  if isinstance(command_args, list):
436
447
  cmd = " ".join(str(part) for part in command_args)
437
- cmd_display = _truncate(cmd, 120)
448
+ cmd_display = _truncate(cmd, 200)
438
449
  cwd = _lookup_arg(args, "cwd")
439
450
  cwd_display = _normalize_cwd_for_display(cwd, project_root)
451
+ prefix_parts = []
440
452
  if cwd_display:
441
- return _truncate(f"cwd={cwd_display} {cmd_display}", 120)
453
+ prefix_parts.append(f"cwd={cwd_display}")
454
+ if env_summary:
455
+ prefix_parts.append(f"env={env_summary}")
456
+ if prefix_parts:
457
+ return _truncate(f"{' '.join(prefix_parts)} {cmd_display}", 200)
442
458
  return cmd_display
443
459
  # 回退:兼容非标准格式(如 command 字段)
444
460
  command = _lookup_arg(args, "command")
445
461
  cwd = _lookup_arg(args, "cwd")
446
462
  cwd_display = _normalize_cwd_for_display(cwd, project_root)
463
+ prefix_parts = []
447
464
  if cwd_display:
448
- return _truncate(f"cwd={cwd_display} command={_truncate(str(command), 120)}", 120) if command else _compact_json(args)
449
- return _truncate(f"command={_truncate(str(command), 120)}", 120) if command else _compact_json(args)
465
+ prefix_parts.append(f"cwd={cwd_display}")
466
+ if env_summary:
467
+ prefix_parts.append(f"env={env_summary}")
468
+ if prefix_parts and command:
469
+ return _truncate(f"{' '.join(prefix_parts)} command={_truncate(str(command), 200)}", 200)
470
+ if prefix_parts:
471
+ return _truncate(" ".join(prefix_parts), 200)
472
+ return _truncate(f"command={_truncate(str(command), 200)}", 200) if command else _compact_json(args)
450
473
  if lowered == "skill":
451
474
  skill_name = _lookup_arg(args, "skill_name", "skill")
452
475
  return _truncate(str(skill_name).strip(), TOOL_SUMMARY_MAX_LENGTH) if skill_name else ""
@@ -101,17 +101,25 @@ def _truncate_file_history(path: Path, max_entries: int = 200) -> None:
101
101
  """Truncate a prompt_toolkit FileHistory file to at most max_entries."""
102
102
  if not path.exists():
103
103
  return
104
- content = path.read_text()
105
- if not content.strip():
104
+ raw = path.read_bytes()
105
+ if not raw.strip():
106
106
  return
107
+ content = raw.decode("utf-8")
108
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
107
109
  # FileHistory format: entries are +prefixed line blocks separated by blank lines
108
- blocks = content.split("\n\n")
110
+ blocks = normalized.split("\n\n")
109
111
  # Filter out empty blocks
110
112
  blocks = [b for b in blocks if b.strip()]
111
- if len(blocks) <= max_entries:
113
+ if not blocks:
112
114
  return
113
115
  kept = blocks[-max_entries:]
114
- path.write_text("\n\n".join(kept) + "\n\n")
116
+ rewritten = "\n\n".join(kept) + "\n\n"
117
+ rewritten_bytes = rewritten.encode("utf-8")
118
+ if rewritten_bytes == raw:
119
+ return
120
+ # Canonicalize to UTF-8 + LF so prompt_toolkit can reload history consistently
121
+ # across platforms, including Windows.
122
+ path.write_bytes(rewritten_bytes)
115
123
 
116
124
 
117
125
  class TerminalAgentTUI(
@@ -739,11 +747,18 @@ class TerminalAgentTUI(
739
747
  session_cwd = getattr(self._session, "_cwd", None)
740
748
  cwd = str(Path(session_cwd).expanduser().resolve()) if session_cwd else str(Path.cwd().resolve())
741
749
  history_path = _get_input_history_path(cwd)
742
- _truncate_file_history(history_path, _INPUT_HISTORY_MAX_ENTRIES)
743
- return FileHistory(str(history_path))
744
750
  except Exception:
745
751
  logger.debug("Failed to create FileHistory, falling back to InMemoryHistory", exc_info=True)
746
752
  return InMemoryHistory()
753
+ try:
754
+ _truncate_file_history(history_path, _INPUT_HISTORY_MAX_ENTRIES)
755
+ except Exception:
756
+ logger.warning(
757
+ "Failed to normalize input history file %s; continuing with FileHistory",
758
+ history_path,
759
+ exc_info=True,
760
+ )
761
+ return FileHistory(str(history_path))
747
762
 
748
763
  def _input_placeholder_hint(self) -> str | None:
749
764
  if self._ui_mode != UIMode.NORMAL:
@@ -231,6 +231,7 @@ class CommandsMixin:
231
231
  manager = getattr(self._session._agent, "_mcp_manager", None)
232
232
  info_rows = list(getattr(manager, "tool_infos", []) or []) if manager is not None else []
233
233
  failed_rows = list(getattr(manager, "failed_servers", []) or []) if manager is not None else []
234
+ state_rows = dict(getattr(manager, "server_states", {}) or {}) if manager is not None else {}
234
235
 
235
236
  tools_by_alias: dict[str, list[dict[str, str]]] = {}
236
237
  tool_count_by_alias: dict[str, int] = {}
@@ -262,13 +263,32 @@ class CommandsMixin:
262
263
  tool_count = int(tool_count_by_alias.get(alias, 0))
263
264
  tools = list(tools_by_alias.get(alias, []))
264
265
  tools.sort(key=lambda item: item["remote_name"].lower())
266
+ state = state_rows.get(alias)
267
+ raw_status = str(getattr(state, "status", "") or "").strip().lower()
268
+ state_reason = str(getattr(state, "reason", "") or "").strip() or None
269
+ state_tool_count = int(getattr(state, "tool_count", tool_count) or 0)
270
+ if raw_status == "connected":
271
+ tool_count = state_tool_count
265
272
 
266
273
  if alias in failed_reason_by_alias:
267
274
  status = f"✗ {failed_reason_by_alias[alias]}"
275
+ raw_status = "failed"
268
276
  elif manager is None:
269
277
  status = "… Not initialized"
270
- else:
278
+ raw_status = "idle"
279
+ elif raw_status == "connecting":
280
+ status = "… Connecting"
281
+ elif raw_status == "failed":
282
+ status = f"✗ {state_reason}" if state_reason else "✗ Not connected"
283
+ tool_count = 0
284
+ elif raw_status == "connected":
271
285
  status = "✓ Connected"
286
+ elif tool_count > 0:
287
+ status = "✓ Connected"
288
+ raw_status = "connected"
289
+ else:
290
+ status = "… Not initialized"
291
+ raw_status = "idle"
272
292
 
273
293
  if alias in project_servers:
274
294
  config_location = str(project_path)
@@ -283,6 +303,7 @@ class CommandsMixin:
283
303
  "server_type": server_type,
284
304
  "endpoint": endpoint or "(empty)",
285
305
  "status": status,
306
+ "raw_status": raw_status,
286
307
  "tool_count": tool_count,
287
308
  "tools": tools,
288
309
  "config_location": config_location,
@@ -376,7 +397,8 @@ class CommandsMixin:
376
397
 
377
398
  tool_count = int(row["tool_count"])
378
399
  options: list[dict[str, str]] = []
379
- if tool_count > 0:
400
+ raw_status = str(row.get("raw_status", "")).strip().lower()
401
+ if raw_status == "connected" and tool_count > 0:
380
402
  options.append(
381
403
  {
382
404
  "value": "view_tools",
@@ -384,13 +406,14 @@ class CommandsMixin:
384
406
  "description": f"{tool_count} tools",
385
407
  }
386
408
  )
387
- options.append(
388
- {
389
- "value": "reconnect",
390
- "label": "Reconnect",
391
- "description": "Reconnect to this server",
392
- }
393
- )
409
+ if raw_status != "connecting":
410
+ options.append(
411
+ {
412
+ "value": "reconnect",
413
+ "label": "Reconnect",
414
+ "description": "Reconnect to this server",
415
+ }
416
+ )
394
417
  options.append(
395
418
  {
396
419
  "value": "back",
@@ -454,6 +477,9 @@ class CommandsMixin:
454
477
  def _start_mcp_reconnect(self, alias: str, rows_by_alias: dict[str, dict[str, Any]]) -> None:
455
478
  """启动 MCP server 重连流程。"""
456
479
  agent_runtime = self._session._agent
480
+ row = rows_by_alias.get(alias, {})
481
+ server_type = str(row.get("server_type", "")).strip().upper()
482
+ connect_timeout_s = 30.0 if server_type in {"STDIO", "SDK"} else 10.0
457
483
 
458
484
  def on_done(success: bool, error_message: str) -> None:
459
485
  if success:
@@ -492,7 +518,12 @@ class CommandsMixin:
492
518
  )
493
519
 
494
520
  retry_coro = agent_runtime.retry_mcp_server(alias)
495
- self._mcp_connecting_view.enter(alias, retry_coro, on_done)
521
+ self._mcp_connecting_view.enter(
522
+ alias,
523
+ retry_coro,
524
+ on_done,
525
+ timeout_s=connect_timeout_s,
526
+ )
496
527
  self._ui_mode = UIMode.MCP_CONNECTING
497
528
  self._sync_focus_for_mode()
498
529
  self._invalidate()
@@ -9,6 +9,7 @@ from prompt_toolkit.application import run_in_terminal
9
9
  from rich.console import Console
10
10
 
11
11
  from comate_agent_sdk.context.items import ItemType
12
+ from comate_agent_sdk.context.items import TOOL_OBSERVABLE_INPUTS_METADATA_KEY
12
13
  from comate_agent_sdk.llm.messages import AssistantMessage, UserMessage
13
14
 
14
15
  from comate_cli.terminal_agent.history_printer import (
@@ -102,6 +103,13 @@ class HistorySyncMixin:
102
103
  args_dict = json.loads(args_str) if args_str else {}
103
104
  except json.JSONDecodeError:
104
105
  args_dict = {"_raw": args_str}
106
+ observable_inputs = (getattr(item, "metadata", {}) or {}).get(
107
+ TOOL_OBSERVABLE_INPUTS_METADATA_KEY
108
+ )
109
+ if isinstance(observable_inputs, dict):
110
+ observable_input = observable_inputs.get(tc.id)
111
+ if isinstance(observable_input, dict):
112
+ args_dict = observable_input
105
113
  signature = self._build_resume_tool_signature(tool_name, args_dict)
106
114
  # 存储映射,不调用 restore_tool_call(避免加入 _running_tools)
107
115
  tool_call_info[tc.id] = (tool_name, signature)
@@ -138,9 +146,9 @@ class HistorySyncMixin:
138
146
  # Extract metadata from ContextItem
139
147
  item_metadata = getattr(item, "metadata", {}) or {}
140
148
 
141
- # Extract diff from raw_envelope for Edit/MultiEdit
149
+ # Extract diff from raw_envelope for Edit
142
150
  diff_lines: list[str] | None = None
143
- if tool_name in ("Edit", "MultiEdit") and not is_error:
151
+ if tool_name == "Edit" and not is_error:
144
152
  envelope = item_metadata.get("tool_raw_envelope")
145
153
  if isinstance(envelope, dict):
146
154
  data = envelope.get("data", {})