comate-cli 0.5.4__tar.gz → 0.5.6__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 (140) hide show
  1. {comate_cli-0.5.4 → comate_cli-0.5.6}/PKG-INFO +1 -1
  2. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/event_renderer.py +29 -7
  3. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/history_printer.py +8 -1
  4. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/rpc_stdio.py +1 -1
  5. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/selection_menu.py +17 -13
  6. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tool_view.py +2 -3
  7. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui.py +158 -87
  8. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/commands.py +83 -14
  9. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/input_behavior.py +23 -28
  10. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/key_bindings.py +74 -12
  11. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/render_panels.py +49 -10
  12. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +1 -1
  13. {comate_cli-0.5.4 → comate_cli-0.5.6}/pyproject.toml +1 -1
  14. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_completion_status_panel.py +3 -9
  15. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_event_renderer.py +22 -9
  16. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_event_renderer_boundary.py +13 -1
  17. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_event_renderer_streaming.py +104 -2
  18. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_history_printer.py +46 -0
  19. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_history_sync.py +2 -4
  20. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_interrupt_exit_semantics.py +16 -2
  21. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_question_key_bindings.py +15 -2
  22. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_rpc_stdio_bridge.py +2 -1
  23. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_selection_menu.py +34 -0
  24. comate_cli-0.5.6/tests/test_skills_slash_command.py +386 -0
  25. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_task_panel_key_bindings.py +16 -1
  26. comate_cli-0.5.6/tests/test_tui_esc_queue.py +257 -0
  27. comate_cli-0.5.6/tests/test_tui_mcp_init_gate.py +80 -0
  28. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_tui_paste_placeholder.py +0 -170
  29. comate_cli-0.5.6/tests/test_tui_queue_preview.py +151 -0
  30. comate_cli-0.5.6/tests/test_tui_queue_sdk_source.py +340 -0
  31. {comate_cli-0.5.4 → comate_cli-0.5.6}/uv.lock +1438 -1453
  32. comate_cli-0.5.4/tests/test_skills_slash_command.py +0 -205
  33. comate_cli-0.5.4/tests/test_tui_mcp_init_gate.py +0 -107
  34. {comate_cli-0.5.4 → comate_cli-0.5.6}/.gitignore +0 -0
  35. {comate_cli-0.5.4 → comate_cli-0.5.6}/README.md +0 -0
  36. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/__init__.py +0 -0
  37. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/__main__.py +0 -0
  38. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/main.py +0 -0
  39. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/mcp_cli.py +0 -0
  40. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/__init__.py +0 -0
  41. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/animations.py +0 -0
  42. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/app.py +0 -0
  43. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/assistant_render.py +0 -0
  44. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/codenames.py +0 -0
  45. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  46. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/env_utils.py +0 -0
  47. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/error_display.py +0 -0
  48. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/figures.py +0 -0
  49. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  50. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/input_geometry.py +0 -0
  51. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  52. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  53. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/logo.py +0 -0
  54. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/markdown_render.py +0 -0
  55. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/mention_completer.py +0 -0
  56. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/message_style.py +0 -0
  57. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/models.py +0 -0
  58. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/path_context_hint.py +0 -0
  59. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/__init__.py +0 -0
  60. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/__init__.py +0 -0
  61. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/detail_view.py +0 -0
  62. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/plugin_list.py +0 -0
  63. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/search_box.py +0 -0
  64. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/tab_bar.py +0 -0
  65. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/marketplace_install_view.py +0 -0
  66. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/plugin_picker.py +0 -0
  67. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/__init__.py +0 -0
  68. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/discover_tab.py +0 -0
  69. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/errors_tab.py +0 -0
  70. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/installed_tab.py +0 -0
  71. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/marketplaces_tab.py +0 -0
  72. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/preflight.py +0 -0
  73. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/question_view.py +0 -0
  74. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/resume_selector.py +0 -0
  75. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/rewind_store.py +0 -0
  76. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  77. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/session_view.py +0 -0
  78. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/slash_commands.py +0 -0
  79. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/startup.py +0 -0
  80. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/status_bar.py +0 -0
  81. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/text_effects.py +0 -0
  82. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tips.py +0 -0
  83. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  84. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  85. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/mcp_connecting_view.py +0 -0
  86. {comate_cli-0.5.4 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  87. {comate_cli-0.5.4 → comate_cli-0.5.6}/docs/superpowers/plans/2026-04-03-phrase-shuffle.md +0 -0
  88. {comate_cli-0.5.4 → comate_cli-0.5.6}/docs/superpowers/specs/2026-04-01-conditional-diff-subtitle-design.md +0 -0
  89. {comate_cli-0.5.4 → comate_cli-0.5.6}/docs/superpowers/specs/2026-04-03-phrase-shuffle-design.md +0 -0
  90. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/conftest.py +0 -0
  91. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_animator_shuffle.py +0 -0
  92. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_app_mcp_preload.py +0 -0
  93. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_app_preflight_gate.py +0 -0
  94. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_app_print_mode.py +0 -0
  95. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_app_shutdown.py +0 -0
  96. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_app_usage_line.py +0 -0
  97. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_cli_project_root.py +0 -0
  98. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_compact_command_semantics.py +0 -0
  99. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_completion_context_activation.py +0 -0
  100. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_context_command.py +0 -0
  101. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_custom_slash_commands.py +0 -0
  102. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_discover_tab.py +0 -0
  103. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_errors_tab.py +0 -0
  104. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_event_renderer_e2e.py +0 -0
  105. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_format_error.py +0 -0
  106. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_handle_error.py +0 -0
  107. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_input_behavior.py +0 -0
  108. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_input_history.py +0 -0
  109. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_installed_tab.py +0 -0
  110. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_layout_coordinator.py +0 -0
  111. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_logging_adapter.py +0 -0
  112. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_logo.py +0 -0
  113. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_main_args.py +0 -0
  114. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_markdown_render.py +0 -0
  115. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_marketplaces_tab.py +0 -0
  116. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_mcp_cli.py +0 -0
  117. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_mcp_slash_command.py +0 -0
  118. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_mention_completer.py +0 -0
  119. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_path_context_hint.py +0 -0
  120. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_plugin_slash_commands.py +0 -0
  121. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_plugin_tui_components.py +0 -0
  122. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_preflight.py +0 -0
  123. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_preflight_copilot.py +0 -0
  124. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_question_view.py +0 -0
  125. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_resume_selector.py +0 -0
  126. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_rewind_command_semantics.py +0 -0
  127. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_rewind_store.py +0 -0
  128. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_rpc_protocol.py +0 -0
  129. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_slash_argument_hint.py +0 -0
  130. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_slash_completer.py +0 -0
  131. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_slash_registry.py +0 -0
  132. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_status_bar.py +0 -0
  133. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_status_bar_transient.py +0 -0
  134. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_task_panel_format.py +0 -0
  135. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_task_panel_rendering.py +0 -0
  136. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_task_poll.py +0 -0
  137. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_tool_view.py +0 -0
  138. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_tui_elapsed_status.py +0 -0
  139. {comate_cli-0.5.4 → comate_cli-0.5.6}/tests/test_tui_split_invariance.py +0 -0
  140. {comate_cli-0.5.4 → comate_cli-0.5.6}/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.4
3
+ Version: 0.5.6
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
@@ -279,12 +279,13 @@ class EventRenderer:
279
279
  self._show_thinking_cb: Callable[[], bool] = lambda: True
280
280
  self._turn_received_text_delta: bool = False
281
281
  self._turn_received_thinking_delta: bool = False
282
+ self._text_delta_started: bool = False
282
283
  # ━━━━━ spec §4.1 新管道状态字段(Phase 1.2 引入,Phase 4-6 接入) ━━━━━
283
284
  # Pipeline A: text line-commit
284
285
  self._text_pending: str = ""
285
286
  self._held_pipe_line: str | None = None
286
287
  self._container: ContainerState | None = None
287
- # Pipeline B: thinking batch
288
+ # Pipeline B: thinking line-commit + tail
288
289
  self._thinking_batch: str = ""
289
290
  # Pipeline C: loading 行 aux
290
291
  self._loading_aux_text: str = ""
@@ -367,7 +368,7 @@ class EventRenderer:
367
368
  self._loading_aux_text = ""
368
369
 
369
370
  def _flush_thinking_batch(self) -> None:
370
- """spec §6.5:thinking batch 整段 → HistoryEntry(受 _show_thinking_cb 过滤)。
371
+ """spec §6.5:thinking 残段 → HistoryEntry(受 _show_thinking_cb 过滤)。
371
372
  即使 hidden 也清空 batch 避免持续累积。"""
372
373
  text = self._thinking_batch
373
374
  self._thinking_batch = ""
@@ -379,6 +380,22 @@ class EventRenderer:
379
380
  HistoryEntry(entry_type="thinking", text=text)
380
381
  )
381
382
 
383
+ def _consume_thinking_delta(self, delta: str) -> None:
384
+ """spec §6.1:thinking delta 主入口。
385
+
386
+ 与 text delta 对称:按完整行即时 commit 到 scrollback,未换行尾巴留在
387
+ _thinking_batch,等待 text/tool/turn 边界通过 _flush_thinking_batch 落盘。
388
+ """
389
+ if not delta:
390
+ return
391
+ self._thinking_batch += delta
392
+ while "\n" in self._thinking_batch:
393
+ line, self._thinking_batch = self._thinking_batch.split("\n", 1)
394
+ if self._show_thinking_cb():
395
+ self._append_history_entry(
396
+ HistoryEntry(entry_type="thinking", text=line + "\n")
397
+ )
398
+
382
399
  # ── 行级路由(spec §6.2,Task 4.1-4.4 渐进扩展)──
383
400
 
384
401
  def _handle_complete_line(self, line: str) -> None:
@@ -457,7 +474,7 @@ class EventRenderer:
457
474
 
458
475
  把 delta 累到 _text_pending,按 \\n 切完整行送 _handle_complete_line。
459
476
 
460
- 关键顺序保证:text 进入前先 flush 待 thinking batch —— 保证
477
+ 关键顺序保证:text 进入前先 flush 待 thinking 残段 —— 保证
461
478
  scrollback 看到 "thinking → text" 而非 "text → thinking" 错序。
462
479
  """
463
480
  if not delta:
@@ -467,6 +484,9 @@ class EventRenderer:
467
484
  self._text_pending += delta
468
485
  while "\n" in self._text_pending:
469
486
  line, self._text_pending = self._text_pending.split("\n", 1)
487
+ if line == "" and not self._text_delta_started:
488
+ continue
489
+ self._text_delta_started = True
470
490
  self._handle_complete_line(line)
471
491
 
472
492
  # ── turn 边界(spec §6.4)──
@@ -509,6 +529,7 @@ class EventRenderer:
509
529
  self._container = None
510
530
  self._thinking_batch = ""
511
531
  self._loading_aux_text = ""
532
+ self._text_delta_started = False
512
533
 
513
534
  def _should_drop_duplicate_system_message(
514
535
  self,
@@ -541,6 +562,7 @@ class EventRenderer:
541
562
  self._pending_tool_starts.clear()
542
563
  self._turn_received_text_delta = False
543
564
  self._turn_received_thinking_delta = False
565
+ self._text_delta_started = False
544
566
  # spec §6.4:清空新 5 字段(每 turn 起点保证状态干净)
545
567
  self._text_pending = ""
546
568
  self._held_pipe_line = None
@@ -630,6 +652,7 @@ class EventRenderer:
630
652
  self._pending_tool_starts.clear()
631
653
  self._turn_received_text_delta = False
632
654
  self._turn_received_thinking_delta = False
655
+ self._text_delta_started = False
633
656
  # spec §6.4:清空 5 个新字段(与 start_turn 同语义)
634
657
  self._text_pending = ""
635
658
  self._held_pipe_line = None
@@ -1444,10 +1467,9 @@ class EventRenderer:
1444
1467
  self._turn_received_text_delta = True
1445
1468
  case ThinkingDeltaEvent(delta=delta, message_id=_):
1446
1469
  if delta:
1447
- # spec §6.1:thinking delta → batch 累加,段结束(StopEvent /
1448
- # text delta 来时)才一次性 commit
1449
- self._thinking_batch += delta
1470
+ # spec §6.1:thinking delta → \n 切完整行入队,残段留 batch
1450
1471
  self._turn_received_thinking_delta = True
1472
+ self._consume_thinking_delta(delta)
1451
1473
  case ToolCallStartEvent(tool_call_id=tool_call_id, tool=tool_name):
1452
1474
  # tool 调用前先把 _text_pending / _thinking_batch 落到 scrollback,
1453
1475
  # 保证说明文字出现在 tool UI 之前(无 \n 结尾的模型也适用)。
@@ -1625,7 +1647,7 @@ class EventRenderer:
1625
1647
  return (True, None)
1626
1648
  if reason == "waiting_for_plan_approval":
1627
1649
  return (False, None)
1628
- if reason == "interrupted":
1650
+ if reason == "interrupted" and "error" not in event.metadata:
1629
1651
  self._append_history_entry(
1630
1652
  HistoryEntry(entry_type="system", text="Current task interrupted.", severity="warning")
1631
1653
  )
@@ -103,6 +103,7 @@ def render_history_group(
103
103
  in_run_continuation = is_assistant and prev_was_assistant
104
104
  next_entry = entries[idx + 1] if idx + 1 < len(entries) else None
105
105
  next_is_assistant = next_entry is not None and next_entry.entry_type == "assistant"
106
+ next_is_thinking = next_entry is not None and next_entry.entry_type == "thinking"
106
107
 
107
108
  # 跳过位于两个块标记 entry 之间的空 assistant entry(loose list 视觉对齐)
108
109
  if is_assistant and _is_blank_assistant(entry):
@@ -127,7 +128,13 @@ def render_history_group(
127
128
  line_text.append(" ", style="dim")
128
129
  line_text.append(line, style="dim")
129
130
  renderables.append(line_text)
130
- renderables.append(Text(""))
131
+ is_batch_tail = next_entry is None
132
+ suppress_trailing_gap = (
133
+ is_batch_tail
134
+ and assume_run_continues_at_tail
135
+ )
136
+ if not next_is_thinking and not suppress_trailing_gap:
137
+ renderables.append(Text(""))
131
138
  prev_was_assistant = False
132
139
  continue
133
140
 
@@ -228,7 +228,7 @@ class StdioRPCBridge:
228
228
  self._active_prompt_result = result_future
229
229
 
230
230
  try:
231
- await self._session.send(user_input)
231
+ _ = await self._session.send(user_input)
232
232
  except Exception as exc:
233
233
  self._clear_active_prompt(result_future)
234
234
  await self._send(
@@ -288,14 +288,13 @@ class SelectionMenuUI:
288
288
  desc_style = "class:selection.description.selected" if is_selected else "class:selection.description"
289
289
 
290
290
  if self._state.layout_mode == "inline":
291
- fragments.append(
292
- (
293
- line_style,
294
- self._inline_option_line(
295
- option=option,
296
- cursor=cursor,
297
- marker=marker,
298
- ),
291
+ fragments.extend(
292
+ self._inline_option_fragments(
293
+ option=option,
294
+ cursor=cursor,
295
+ marker=marker,
296
+ line_style=line_style,
297
+ desc_style=desc_style,
299
298
  )
300
299
  )
301
300
  fragments.append(("", "\n"))
@@ -331,13 +330,15 @@ class SelectionMenuUI:
331
330
  )
332
331
  return fragments
333
332
 
334
- def _inline_option_line(
333
+ def _inline_option_fragments(
335
334
  self,
336
335
  *,
337
336
  option: SelectionOption,
338
337
  cursor: str,
339
338
  marker: str,
340
- ) -> str:
339
+ line_style: str,
340
+ desc_style: str,
341
+ ) -> list[tuple[str, str]]:
341
342
  total_width = max(20, int(self._state.inline_total_width or 100))
342
343
  prefix = f" {cursor} {marker} "
343
344
  prefix_width = get_cwidth(prefix)
@@ -357,13 +358,16 @@ class SelectionMenuUI:
357
358
  name_padding = " " * max(0, name_width - get_cwidth(name_text))
358
359
 
359
360
  if desc_width <= 0:
360
- return fit_single_line(f"{prefix}{name_text}", total_width)
361
+ return [(line_style, fit_single_line(f"{prefix}{name_text}", total_width))]
361
362
 
362
363
  description = option.description.strip()
363
364
  desc_text = fit_single_line(description, desc_width) if description else ""
364
365
  if desc_text:
365
- return f"{prefix}{name_text}{name_padding}{' ' * gap_width}{desc_text}"
366
- return f"{prefix}{name_text}{name_padding}"
366
+ return [
367
+ (line_style, f"{prefix}{name_text}{name_padding}"),
368
+ (desc_style, f"{' ' * gap_width}{desc_text}"),
369
+ ]
370
+ return [(line_style, f"{prefix}{name_text}{name_padding}")]
367
371
 
368
372
  def _normalized_page_size(self) -> int:
369
373
  options_count = len(self._state.options)
@@ -93,11 +93,10 @@ def should_show_tool_in_scrollback(
93
93
  return is_result and is_error
94
94
 
95
95
  if lowered == "taskcreate":
96
- return is_result
96
+ return is_result and is_error
97
97
 
98
98
  if lowered == "taskupdate":
99
- status = str(args.get("status", "")).strip().lower()
100
- return is_result and (is_error or status == "completed")
99
+ return is_result and is_error
101
100
 
102
101
  # Read-only task tools: only show errors.
103
102
  if lowered in _SILENT_TASK_TOOLS:
@@ -9,7 +9,6 @@ import asyncio
9
9
  import logging
10
10
  import random
11
11
  import time
12
- from collections import deque
13
12
  from collections.abc import Callable
14
13
  from contextlib import suppress
15
14
  from pathlib import Path
@@ -34,10 +33,14 @@ from comate_agent_sdk.agent import ChatSession
34
33
  from comate_agent_sdk.agent.events import (
35
34
  ExternalTurnScheduledEvent,
36
35
  PlanApprovalRequiredEvent,
36
+ QueueDirtyEvent,
37
+ QueueDirtyReason,
38
+ QueuedMessageInjectedEvent,
37
39
  SessionInitEvent,
38
40
  StopEvent,
39
41
  TeamMessageEvent,
40
42
  )
43
+ from comate_agent_sdk.agent.queue_types import MessageOrigin
41
44
 
42
45
  from comate_cli.terminal_agent.animations import (
43
46
  DEFAULT_STATUS_PHRASES,
@@ -196,22 +199,21 @@ class TerminalAgentTUI(
196
199
  "AGENT_SDK_TUI_TODO_PANEL_EXPANDED_MAX_LINES",
197
200
  12,
198
201
  )
199
- self._message_queue_max_size = read_env_int(
200
- "AGENT_SDK_TUI_MESSAGE_QUEUE_MAX_SIZE",
201
- 3,
202
- )
203
202
  self._queued_preview_max_chars = read_env_int(
204
203
  "AGENT_SDK_TUI_QUEUED_PREVIEW_MAX_CHARS",
205
204
  24,
206
205
  )
206
+ self._queued_preview_max_rows = read_env_int(
207
+ "AGENT_SDK_TUI_QUEUED_PREVIEW_MAX_ROWS",
208
+ 3,
209
+ )
207
210
 
208
211
  self._busy = False
209
- self._initializing = False # MCP 加载中
210
212
  self._is_compacting = False
211
213
  self._compact_task: asyncio.Task[Any] | None = None
212
214
  self._compact_cancel_requested = False
213
215
  self._pending_exit_after_compact_cancel = False
214
- self._queued_messages: deque[str] = deque() # busy 时入队的消息(FIFO)
216
+ self._queued_display_by_message_id: dict[str, str] = {}
215
217
  self._waiting_for_input = False
216
218
  self._pending_questions: list[dict[str, Any]] | None = None
217
219
  self._pending_plan_approval: dict[str, str] | None = None
@@ -220,6 +222,7 @@ class TerminalAgentTUI(
220
222
  self._renderer._show_thinking_cb = lambda: self._show_thinking
221
223
 
222
224
  self._custom_slash_commands: dict[str, CustomSlashCommand] = {}
225
+ self._skill_slash_command_names: set[str] = set()
223
226
  self._slash_registry = SlashCommandRegistry()
224
227
  self._build_slash_registry()
225
228
  self._slash_completer = SlashCommandCompleter(
@@ -235,6 +238,7 @@ class TerminalAgentTUI(
235
238
  deduplicate=True,
236
239
  )
237
240
  )
241
+ self._sync_skill_slash_commands()
238
242
  self._loading_frame = 0
239
243
  self._fallback_loading_phrase = (
240
244
  random.choice(DEFAULT_STATUS_PHRASES)
@@ -288,10 +292,6 @@ class TerminalAgentTUI(
288
292
  self._ctrl_c_press_count: int = 0
289
293
  ctrl_c_window_ms = read_env_int("AGENT_SDK_TUI_CTRL_C_EXIT_WINDOW_MS", 700)
290
294
  self._ctrl_c_exit_window_seconds = ctrl_c_window_ms / 1000.0
291
- self._mcp_init_gate_timeout_s = read_env_float(
292
- "AGENT_SDK_TUI_MCP_INIT_GATE_TIMEOUT_S",
293
- 8.0,
294
- )
295
295
  self._mcp_init_cancel_timeout_s = read_env_float(
296
296
  "AGENT_SDK_TUI_MCP_INIT_CANCEL_TIMEOUT_S",
297
297
  1.0,
@@ -559,8 +559,8 @@ class TerminalAgentTUI(
559
559
  "status.transient.warning": "bg:default italic fg:ansiyellow",
560
560
  "input.placeholder": "bg:default #9CA3AF",
561
561
  "auto-suggestion": "bg:default #94a3b8",
562
- "queue": "bg:#1d222a #d8dee9",
563
- "queue.item": "bg:#1d222a #cbd5e1",
562
+ "queue": "bg:default #d8dee9",
563
+ "queue.item": "bg:default #cbd5e1",
564
564
  "git-diff.added": "#4ade80",
565
565
  "git-diff.removed": "#f87171",
566
566
  "question.tabs": "bg:default #c7d2fe",
@@ -638,9 +638,7 @@ class TerminalAgentTUI(
638
638
  )
639
639
 
640
640
  def _should_show_queue_panel(self) -> bool:
641
- return self._ui_mode == UIMode.NORMAL and len(self._queued_messages) >= 1
642
-
643
- _QUEUED_SLASH_PREFIX = "__COMATE_QUEUED_SLASH__::"
641
+ return self._ui_mode == UIMode.NORMAL and bool(self._queued_human_messages())
644
642
 
645
643
  def _build_slash_registry(self) -> None:
646
644
  handlers: dict[str, Callable[[str], Any]] = {
@@ -723,31 +721,61 @@ class TerminalAgentTUI(
723
721
  argument_hint=command.argument_hint,
724
722
  )
725
723
 
726
- def _resolve_slash_spec(self, name: str) -> SlashCommandSpec | None:
727
- entry = self._slash_registry.resolve(name)
728
- if entry is None:
729
- return None
730
- return entry.spec
724
+ def _sync_skill_slash_commands(self) -> None:
725
+ """同步当前 runtime skills 到 slash command registry。"""
726
+ for name in list(getattr(self, "_skill_slash_command_names", set())):
727
+ self._slash_registry.unregister(name)
728
+ self._skill_slash_command_names = set()
731
729
 
732
- def _encode_queued_slash(self, raw_command: str) -> str:
733
- return f"{self._QUEUED_SLASH_PREFIX}{raw_command}"
730
+ agent = getattr(self._session, "_agent", None)
731
+ runtime_state = getattr(agent, "_runtime_state", None)
732
+ skills = list(getattr(runtime_state, "skills", []) or [])
733
+ seen_names: set[str] = set()
734
734
 
735
- def _is_encoded_queued_slash(self, value: str) -> bool:
736
- return value.startswith(self._QUEUED_SLASH_PREFIX)
735
+ for skill in skills:
736
+ if not bool(getattr(skill, "user_invocable", True)):
737
+ continue
737
738
 
738
- def _decode_queued_slash(self, value: str) -> str:
739
- if not self._is_encoded_queued_slash(value):
740
- return value
741
- return value[len(self._QUEUED_SLASH_PREFIX) :]
739
+ name = str(getattr(skill, "name", "")).strip()
740
+ if not name or name in seen_names:
741
+ continue
742
+ seen_names.add(name)
742
743
 
743
- def _queued_preview_text(self, value: str) -> str:
744
- return self._decode_queued_slash(value)
744
+ if self._slash_registry.resolve(name) is not None:
745
+ logger.warning(
746
+ "Skill '%s' conflicts with an existing slash command, skipping",
747
+ name,
748
+ )
749
+ continue
745
750
 
746
- def _dispatch_queued_item(self, queued: str) -> None:
747
- if self._is_encoded_queued_slash(queued):
748
- self._schedule_background(self._execute_command(self._decode_queued_slash(queued)))
749
- return
750
- self._schedule_background(self._submit_user_message(queued))
751
+ spec = SlashCommandSpec(
752
+ name=name,
753
+ description=str(getattr(skill, "description", "") or "").strip(),
754
+ execution_kind="hybrid",
755
+ argument_hint=getattr(skill, "argument_hint", None),
756
+ )
757
+
758
+ async def _handler(args: str, *, skill_name: str = name) -> None:
759
+ await self._slash_skill(skill_name=skill_name, args=args)
760
+
761
+ self._slash_registry.register(
762
+ spec=spec,
763
+ handler=_handler,
764
+ allow_when_busy=False,
765
+ source="skill",
766
+ argument_hint=spec.argument_hint,
767
+ )
768
+ self._skill_slash_command_names.add(name)
769
+
770
+ slash_completer = getattr(self, "_slash_completer", None)
771
+ if slash_completer is not None:
772
+ slash_completer.update_commands(self._slash_registry.command_specs())
773
+
774
+ def _resolve_slash_spec(self, name: str) -> SlashCommandSpec | None:
775
+ entry = self._slash_registry.resolve(name)
776
+ if entry is None:
777
+ return None
778
+ return entry.spec
751
779
 
752
780
  def _create_input_history(self) -> FileHistory | InMemoryHistory:
753
781
  """Create persistent FileHistory based on cwd, with fallback to InMemoryHistory."""
@@ -771,15 +799,16 @@ class TerminalAgentTUI(
771
799
  def _input_placeholder_hint(self) -> str | None:
772
800
  if self._ui_mode != UIMode.NORMAL:
773
801
  return None
774
- if not self._queued_messages:
802
+ queued_messages = self._queued_human_messages()
803
+ if not queued_messages:
775
804
  return None
776
805
  input_text = str(getattr(getattr(self, "_input_area", None), "text", ""))
777
806
  if input_text.strip():
778
807
  return None
779
- if len(self._queued_messages) >= 2:
808
+ if len(queued_messages) >= 2:
780
809
  return self._queued_input_hint
781
810
 
782
- preview = " ".join(self._queued_preview_text(self._queued_messages[0]).split())
811
+ preview = self._queued_message_preview(queued_messages[0])
783
812
  max_chars = max(8, int(self._queued_preview_max_chars))
784
813
  if len(preview) > max_chars:
785
814
  preview = f"{preview[: max_chars - 3]}..."
@@ -927,10 +956,63 @@ class TerminalAgentTUI(
927
956
  async for event in self._session.events():
928
957
  if self._closing:
929
958
  break
959
+ stop_error: Exception | None = None
930
960
  self._maybe_cancel_tool_result_flash(event)
931
961
  await self._animation_controller.on_event(event)
932
962
  if isinstance(event, SessionInitEvent):
933
963
  self._initialized_session_id = str(event.session_id)
964
+ if isinstance(event, QueueDirtyEvent):
965
+ if event.reason in {
966
+ QueueDirtyReason.CANCELLED,
967
+ QueueDirtyReason.CLEARED,
968
+ }:
969
+ for message_id in event.message_ids:
970
+ self._queued_display_by_message_id.pop(
971
+ str(message_id),
972
+ None,
973
+ )
974
+ if event.reason is QueueDirtyReason.CONSUMED:
975
+ consumed_human_displays: list[str] = []
976
+ for message_id, origin in zip(event.message_ids, event.origins):
977
+ if origin is not MessageOrigin.HUMAN:
978
+ continue
979
+ display_text = self._queued_display_by_message_id.pop(
980
+ str(message_id),
981
+ None,
982
+ )
983
+ if display_text:
984
+ consumed_human_displays.append(display_text)
985
+ if consumed_human_displays and not self._busy:
986
+ self._session.run_controller.clear()
987
+ self._interrupt_requested_at = None
988
+ self._waiting_for_input = False
989
+ self._pending_questions = None
990
+ self._pending_plan_approval = None
991
+ self._set_busy(True)
992
+ self._run_start_time = time.monotonic()
993
+ self._fallback_loading_phrase = (
994
+ random.choice(DEFAULT_STATUS_PHRASES)
995
+ if DEFAULT_STATUS_PHRASES
996
+ else f"Thinking{ELLIPSIS}"
997
+ )
998
+ self._fallback_phrase_refresh_at = time.monotonic() + 3.0
999
+ self._renderer.start_turn()
1000
+ await self._animation_controller.start()
1001
+ self._last_turn_user_preview = consumed_human_displays[-1]
1002
+ for display_text in consumed_human_displays:
1003
+ self._renderer.seed_user_message(display_text)
1004
+ self._refresh_layers()
1005
+ self._invalidate()
1006
+ if isinstance(event, QueuedMessageInjectedEvent):
1007
+ for message_id, origin in zip(event.message_ids, event.origins):
1008
+ if origin is not MessageOrigin.HUMAN:
1009
+ continue
1010
+ display_text = self._queued_display_by_message_id.pop(
1011
+ str(message_id),
1012
+ None,
1013
+ )
1014
+ if display_text:
1015
+ self._renderer.seed_user_message(display_text)
934
1016
  if isinstance(event, ExternalTurnScheduledEvent) and not self._busy:
935
1017
  self._session.run_controller.clear()
936
1018
  self._interrupt_requested_at = None
@@ -954,6 +1036,14 @@ class TerminalAgentTUI(
954
1036
  }
955
1037
  if isinstance(event, StopEvent):
956
1038
  stop_reason = str(event.reason or "")
1039
+ raw_stop_error = event.metadata.get("error")
1040
+ if raw_stop_error is not None:
1041
+ stop_error = (
1042
+ raw_stop_error
1043
+ if isinstance(raw_stop_error, Exception)
1044
+ else Exception(str(raw_stop_error))
1045
+ )
1046
+ stop_reason = "error"
957
1047
  if event.reason == "shutdown_request":
958
1048
  shutdown_recipients = event.metadata.get("shutdown_recipients", [])
959
1049
  if shutdown_recipients:
@@ -969,6 +1059,15 @@ class TerminalAgentTUI(
969
1059
  waiting_for_input = True
970
1060
  if new_questions is not None:
971
1061
  questions = new_questions
1062
+ if stop_error is not None:
1063
+ from comate_cli.terminal_agent.error_display import format_error
1064
+
1065
+ message, transient_summary, severity = format_error(stop_error)
1066
+ self._renderer.append_system_message(message, severity=severity)
1067
+ self._status_bar.show_transient(
1068
+ transient_summary,
1069
+ severity=severity,
1070
+ )
972
1071
  self._refresh_layers()
973
1072
  if isinstance(event, StopEvent):
974
1073
  background_turn = self._background_turn_source is not None
@@ -1013,14 +1112,6 @@ class TerminalAgentTUI(
1013
1112
 
1014
1113
  self._refresh_layers()
1015
1114
 
1016
- if (
1017
- self._queued_messages
1018
- and not waiting_for_input
1019
- and plan_approval is None
1020
- ):
1021
- queued = self._queued_messages.popleft()
1022
- self._dispatch_queued_item(queued)
1023
-
1024
1115
  waiting_for_input = False
1025
1116
  questions = None
1026
1117
  plan_approval = None
@@ -1285,17 +1376,16 @@ class TerminalAgentTUI(
1285
1376
  if self._renderer.has_running_tools():
1286
1377
  # Keep repainting for breathing animation.
1287
1378
  self._render_dirty = True
1288
- elif self._busy or self._initializing:
1379
+ elif self._busy:
1289
1380
  self._render_dirty = True
1290
1381
 
1291
1382
  if self._render_dirty:
1292
1383
  self._invalidate()
1293
1384
  self._render_dirty = False
1294
1385
 
1295
- # 动态帧率:busy/动画/initializing 时降为 6fps(缓解 scrollback 污染),idle 时 4fps
1386
+ # 动态帧率:busy/动画时降为 6fps(缓解 scrollback 污染),idle 时 4fps
1296
1387
  fast = (
1297
1388
  self._busy
1298
- or self._initializing
1299
1389
  or self._animator.is_active
1300
1390
  or self._renderer.has_running_tools()
1301
1391
  )
@@ -1408,45 +1498,23 @@ class TerminalAgentTUI(
1408
1498
  logger.exception("Plugin init failed at startup; continuing without plugins")
1409
1499
 
1410
1500
  if mcp_init is not None:
1411
- self._initializing = True
1412
-
1413
1501
  async def _do_init() -> None:
1414
- init_task = asyncio.create_task(mcp_init(), name="terminal-mcp-init-worker")
1415
1502
  try:
1416
- await asyncio.wait_for(
1417
- asyncio.shield(init_task),
1418
- timeout=self._mcp_init_gate_timeout_s,
1419
- )
1420
- except asyncio.TimeoutError:
1421
- logger.debug(
1422
- "MCP init timed out after "
1423
- f"{self._mcp_init_gate_timeout_s:.1f}s; continuing in background",
1424
- )
1425
- self._status_bar.show_transient(
1426
- f"MCP: init timed out, continuing in background{ELLIPSIS}",
1427
- duration_s=5.0,
1428
- )
1429
- # Don't cancel init_task — let it complete in background.
1430
- # asyncio.shield above already protects it from wait_for's
1431
- # auto-cancellation. When it finishes, runtime._mcp_manager
1432
- # will be set and tools become available for the next turn.
1503
+ await mcp_init()
1433
1504
  except asyncio.CancelledError:
1434
- await self._cancel_task_with_timeout(
1435
- init_task,
1436
- timeout_s=self._mcp_init_cancel_timeout_s,
1437
- task_name="terminal-mcp-init-worker",
1438
- )
1439
- return
1505
+ raise
1440
1506
  except Exception as exc:
1441
- logger.debug(f"MCP init failed in TUI bootstrap: {exc}", exc_info=True)
1507
+ logger.debug(
1508
+ f"MCP init failed in TUI bootstrap: {exc}",
1509
+ exc_info=True,
1510
+ )
1442
1511
  finally:
1443
- self._initializing = False
1444
- if self._queued_messages and not self._busy:
1445
- queued = self._queued_messages.popleft()
1446
- self._dispatch_queued_item(queued)
1447
1512
  self._refresh_layers()
1448
1513
 
1449
- self._mcp_init_task = asyncio.create_task(_do_init(), name="terminal-mcp-init")
1514
+ self._mcp_init_task = asyncio.create_task(
1515
+ _do_init(),
1516
+ name="terminal-mcp-init",
1517
+ )
1450
1518
 
1451
1519
  self._ui_tick_task = asyncio.create_task(
1452
1520
  self._ui_tick(),
@@ -1519,6 +1587,9 @@ class TerminalAgentTUI(
1519
1587
 
1520
1588
  # Commands → SlashCommandRegistry with namespace prefix
1521
1589
  for cmd in plugin.commands:
1590
+ if cmd.name in getattr(self, "_skill_slash_command_names", set()):
1591
+ self._slash_registry.unregister(cmd.name)
1592
+ self._skill_slash_command_names.discard(cmd.name)
1522
1593
  spec = SlashCommandSpec(
1523
1594
  name=cmd.name, # already namespaced: "plugin-name:cmd-name"
1524
1595
  description=cmd.description,
@@ -1576,8 +1647,10 @@ class TerminalAgentTUI(
1576
1647
  if any(p.agents for p in plugins):
1577
1648
  rebuild_agent_tool(agent)
1578
1649
 
1650
+ if any(p.skills for p in plugins):
1651
+ self._sync_skill_slash_commands()
1579
1652
  # Refresh tab-completion so new plugin commands appear
1580
- if any(p.commands for p in plugins):
1653
+ elif any(p.commands for p in plugins):
1581
1654
  self._slash_completer.update_commands(self._slash_registry.command_specs())
1582
1655
 
1583
1656
  def _clear_plugin_resources(self) -> None:
@@ -1615,10 +1688,6 @@ class TerminalAgentTUI(
1615
1688
  self._custom_slash_commands.pop(cmd_name, None)
1616
1689
  self._plugin_command_names = set()
1617
1690
 
1618
- # Refresh tab-completion so removed commands disappear
1619
- if had_commands:
1620
- self._slash_completer.update_commands(self._slash_registry.command_specs())
1621
-
1622
1691
  # Remove plugin MCP servers from frozen config
1623
1692
  has_mcp = any(p.mcp_servers for p in loaded)
1624
1693
  if has_mcp and agent.config.mcp_servers:
@@ -1642,3 +1711,5 @@ class TerminalAgentTUI(
1642
1711
  rebuild_skill_tool(agent)
1643
1712
  if had_agents:
1644
1713
  rebuild_agent_tool(agent)
1714
+ if had_skills or had_commands:
1715
+ self._sync_skill_slash_commands()