comate-cli 0.5.5__tar.gz → 0.5.7__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.5 → comate_cli-0.5.7}/PKG-INFO +1 -1
  2. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/event_renderer.py +1 -1
  3. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/selection_menu.py +17 -13
  4. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui.py +115 -8
  5. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/commands.py +82 -13
  6. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/key_bindings.py +58 -47
  7. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +1 -1
  8. {comate_cli-0.5.5 → comate_cli-0.5.7}/pyproject.toml +1 -1
  9. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_event_renderer_streaming.py +10 -0
  10. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_selection_menu.py +34 -0
  11. comate_cli-0.5.7/tests/test_skills_slash_command.py +386 -0
  12. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_esc_queue.py +40 -1
  13. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_queue_sdk_source.py +68 -1
  14. comate_cli-0.5.7/tests/test_tui_team_messages.py +121 -0
  15. {comate_cli-0.5.5 → comate_cli-0.5.7}/uv.lock +2 -2
  16. comate_cli-0.5.5/tests/test_skills_slash_command.py +0 -205
  17. {comate_cli-0.5.5 → comate_cli-0.5.7}/.gitignore +0 -0
  18. {comate_cli-0.5.5 → comate_cli-0.5.7}/README.md +0 -0
  19. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/__init__.py +0 -0
  20. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/__main__.py +0 -0
  21. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/main.py +0 -0
  22. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/mcp_cli.py +0 -0
  23. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/__init__.py +0 -0
  24. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/animations.py +0 -0
  25. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/app.py +0 -0
  26. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/assistant_render.py +0 -0
  27. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/codenames.py +0 -0
  28. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  29. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/env_utils.py +0 -0
  30. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/error_display.py +0 -0
  31. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/figures.py +0 -0
  32. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  33. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/history_printer.py +0 -0
  34. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/input_geometry.py +0 -0
  35. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  36. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  37. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/logo.py +0 -0
  38. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/markdown_render.py +0 -0
  39. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/mention_completer.py +0 -0
  40. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/message_style.py +0 -0
  41. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/models.py +0 -0
  42. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/path_context_hint.py +0 -0
  43. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/__init__.py +0 -0
  44. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/components/__init__.py +0 -0
  45. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/components/detail_view.py +0 -0
  46. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/components/plugin_list.py +0 -0
  47. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/components/search_box.py +0 -0
  48. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/components/tab_bar.py +0 -0
  49. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/marketplace_install_view.py +0 -0
  50. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/plugin_picker.py +0 -0
  51. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/tabs/__init__.py +0 -0
  52. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/tabs/discover_tab.py +0 -0
  53. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/tabs/errors_tab.py +0 -0
  54. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/tabs/installed_tab.py +0 -0
  55. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/plugins/tabs/marketplaces_tab.py +0 -0
  56. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/preflight.py +0 -0
  57. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/question_view.py +0 -0
  58. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/resume_selector.py +0 -0
  59. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/rewind_store.py +0 -0
  60. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  61. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  62. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/session_view.py +0 -0
  63. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/slash_commands.py +0 -0
  64. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/startup.py +0 -0
  65. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/status_bar.py +0 -0
  66. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/text_effects.py +0 -0
  67. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tips.py +0 -0
  68. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tool_view.py +0 -0
  69. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  70. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  71. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  72. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/mcp_connecting_view.py +0 -0
  73. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
  74. {comate_cli-0.5.5 → comate_cli-0.5.7}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  75. {comate_cli-0.5.5 → comate_cli-0.5.7}/docs/superpowers/plans/2026-04-03-phrase-shuffle.md +0 -0
  76. {comate_cli-0.5.5 → comate_cli-0.5.7}/docs/superpowers/specs/2026-04-01-conditional-diff-subtitle-design.md +0 -0
  77. {comate_cli-0.5.5 → comate_cli-0.5.7}/docs/superpowers/specs/2026-04-03-phrase-shuffle-design.md +0 -0
  78. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/conftest.py +0 -0
  79. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_animator_shuffle.py +0 -0
  80. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_app_mcp_preload.py +0 -0
  81. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_app_preflight_gate.py +0 -0
  82. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_app_print_mode.py +0 -0
  83. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_app_shutdown.py +0 -0
  84. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_app_usage_line.py +0 -0
  85. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_cli_project_root.py +0 -0
  86. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_compact_command_semantics.py +0 -0
  87. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_completion_context_activation.py +0 -0
  88. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_completion_status_panel.py +0 -0
  89. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_context_command.py +0 -0
  90. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_custom_slash_commands.py +0 -0
  91. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_discover_tab.py +0 -0
  92. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_errors_tab.py +0 -0
  93. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_event_renderer.py +0 -0
  94. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_event_renderer_boundary.py +0 -0
  95. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_event_renderer_e2e.py +0 -0
  96. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_format_error.py +0 -0
  97. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_handle_error.py +0 -0
  98. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_history_printer.py +0 -0
  99. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_history_sync.py +0 -0
  100. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_input_behavior.py +0 -0
  101. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_input_history.py +0 -0
  102. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_installed_tab.py +0 -0
  103. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_interrupt_exit_semantics.py +0 -0
  104. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_layout_coordinator.py +0 -0
  105. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_logging_adapter.py +0 -0
  106. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_logo.py +0 -0
  107. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_main_args.py +0 -0
  108. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_markdown_render.py +0 -0
  109. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_marketplaces_tab.py +0 -0
  110. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_mcp_cli.py +0 -0
  111. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_mcp_slash_command.py +0 -0
  112. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_mention_completer.py +0 -0
  113. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_path_context_hint.py +0 -0
  114. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_plugin_slash_commands.py +0 -0
  115. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_plugin_tui_components.py +0 -0
  116. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_preflight.py +0 -0
  117. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_preflight_copilot.py +0 -0
  118. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_question_key_bindings.py +0 -0
  119. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_question_view.py +0 -0
  120. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_resume_selector.py +0 -0
  121. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_rewind_command_semantics.py +0 -0
  122. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_rewind_store.py +0 -0
  123. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_rpc_protocol.py +0 -0
  124. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_rpc_stdio_bridge.py +0 -0
  125. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_slash_argument_hint.py +0 -0
  126. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_slash_completer.py +0 -0
  127. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_slash_registry.py +0 -0
  128. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_status_bar.py +0 -0
  129. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_status_bar_transient.py +0 -0
  130. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_task_panel_format.py +0 -0
  131. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_task_panel_key_bindings.py +0 -0
  132. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_task_panel_rendering.py +0 -0
  133. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_task_poll.py +0 -0
  134. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tool_view.py +0 -0
  135. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_elapsed_status.py +0 -0
  136. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_mcp_init_gate.py +0 -0
  137. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_paste_placeholder.py +0 -0
  138. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_queue_preview.py +0 -0
  139. {comate_cli-0.5.5 → comate_cli-0.5.7}/tests/test_tui_split_invariance.py +0 -0
  140. {comate_cli-0.5.5 → comate_cli-0.5.7}/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.5
3
+ Version: 0.5.7
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
@@ -1647,7 +1647,7 @@ class EventRenderer:
1647
1647
  return (True, None)
1648
1648
  if reason == "waiting_for_plan_approval":
1649
1649
  return (False, None)
1650
- if reason == "interrupted":
1650
+ if reason == "interrupted" and "error" not in event.metadata:
1651
1651
  self._append_history_entry(
1652
1652
  HistoryEntry(entry_type="system", text="Current task interrupted.", severity="warning")
1653
1653
  )
@@ -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)
@@ -179,9 +179,13 @@ class TerminalAgentTUI(
179
179
  from prompt_toolkit.application import get_app
180
180
  from prompt_toolkit.application.dummy import DummyApplication
181
181
  app = get_app()
182
- if not isinstance(app, DummyApplication):
183
- app.create_background_task(run_in_terminal(_write, in_executor=False))
184
- return
182
+ if isinstance(app, DummyApplication):
183
+ _write()
184
+ else:
185
+ # run_in_terminal() 自身已经会调度 Future;不能再交给
186
+ # create_background_task() 二次包装,否则会触发重复执行竞态。
187
+ run_in_terminal(_write, in_executor=False)
188
+ return
185
189
  except Exception:
186
190
  pass
187
191
  _write()
@@ -222,6 +226,7 @@ class TerminalAgentTUI(
222
226
  self._renderer._show_thinking_cb = lambda: self._show_thinking
223
227
 
224
228
  self._custom_slash_commands: dict[str, CustomSlashCommand] = {}
229
+ self._skill_slash_command_names: set[str] = set()
225
230
  self._slash_registry = SlashCommandRegistry()
226
231
  self._build_slash_registry()
227
232
  self._slash_completer = SlashCommandCompleter(
@@ -237,6 +242,7 @@ class TerminalAgentTUI(
237
242
  deduplicate=True,
238
243
  )
239
244
  )
245
+ self._sync_skill_slash_commands()
240
246
  self._loading_frame = 0
241
247
  self._fallback_loading_phrase = (
242
248
  random.choice(DEFAULT_STATUS_PHRASES)
@@ -719,6 +725,56 @@ class TerminalAgentTUI(
719
725
  argument_hint=command.argument_hint,
720
726
  )
721
727
 
728
+ def _sync_skill_slash_commands(self) -> None:
729
+ """同步当前 runtime skills 到 slash command registry。"""
730
+ for name in list(getattr(self, "_skill_slash_command_names", set())):
731
+ self._slash_registry.unregister(name)
732
+ self._skill_slash_command_names = set()
733
+
734
+ agent = getattr(self._session, "_agent", None)
735
+ runtime_state = getattr(agent, "_runtime_state", None)
736
+ skills = list(getattr(runtime_state, "skills", []) or [])
737
+ seen_names: set[str] = set()
738
+
739
+ for skill in skills:
740
+ if not bool(getattr(skill, "user_invocable", True)):
741
+ continue
742
+
743
+ name = str(getattr(skill, "name", "")).strip()
744
+ if not name or name in seen_names:
745
+ continue
746
+ seen_names.add(name)
747
+
748
+ if self._slash_registry.resolve(name) is not None:
749
+ logger.warning(
750
+ "Skill '%s' conflicts with an existing slash command, skipping",
751
+ name,
752
+ )
753
+ continue
754
+
755
+ spec = SlashCommandSpec(
756
+ name=name,
757
+ description=str(getattr(skill, "description", "") or "").strip(),
758
+ execution_kind="hybrid",
759
+ argument_hint=getattr(skill, "argument_hint", None),
760
+ )
761
+
762
+ async def _handler(args: str, *, skill_name: str = name) -> None:
763
+ await self._slash_skill(skill_name=skill_name, args=args)
764
+
765
+ self._slash_registry.register(
766
+ spec=spec,
767
+ handler=_handler,
768
+ allow_when_busy=False,
769
+ source="skill",
770
+ argument_hint=spec.argument_hint,
771
+ )
772
+ self._skill_slash_command_names.add(name)
773
+
774
+ slash_completer = getattr(self, "_slash_completer", None)
775
+ if slash_completer is not None:
776
+ slash_completer.update_commands(self._slash_registry.command_specs())
777
+
722
778
  def _resolve_slash_spec(self, name: str) -> SlashCommandSpec | None:
723
779
  entry = self._slash_registry.resolve(name)
724
780
  if entry is None:
@@ -904,6 +960,7 @@ class TerminalAgentTUI(
904
960
  async for event in self._session.events():
905
961
  if self._closing:
906
962
  break
963
+ stop_error: Exception | None = None
907
964
  self._maybe_cancel_tool_result_flash(event)
908
965
  await self._animation_controller.on_event(event)
909
966
  if isinstance(event, SessionInitEvent):
@@ -918,6 +975,36 @@ class TerminalAgentTUI(
918
975
  str(message_id),
919
976
  None,
920
977
  )
978
+ if event.reason is QueueDirtyReason.CONSUMED:
979
+ consumed_human_displays: list[str] = []
980
+ for message_id, origin in zip(event.message_ids, event.origins):
981
+ if origin is not MessageOrigin.HUMAN:
982
+ continue
983
+ display_text = self._queued_display_by_message_id.pop(
984
+ str(message_id),
985
+ None,
986
+ )
987
+ if display_text:
988
+ consumed_human_displays.append(display_text)
989
+ if consumed_human_displays and not self._busy:
990
+ self._session.run_controller.clear()
991
+ self._interrupt_requested_at = None
992
+ self._waiting_for_input = False
993
+ self._pending_questions = None
994
+ self._pending_plan_approval = None
995
+ self._set_busy(True)
996
+ self._run_start_time = time.monotonic()
997
+ self._fallback_loading_phrase = (
998
+ random.choice(DEFAULT_STATUS_PHRASES)
999
+ if DEFAULT_STATUS_PHRASES
1000
+ else f"Thinking{ELLIPSIS}"
1001
+ )
1002
+ self._fallback_phrase_refresh_at = time.monotonic() + 3.0
1003
+ self._renderer.start_turn()
1004
+ await self._animation_controller.start()
1005
+ self._last_turn_user_preview = consumed_human_displays[-1]
1006
+ for display_text in consumed_human_displays:
1007
+ self._renderer.seed_user_message(display_text)
921
1008
  self._refresh_layers()
922
1009
  self._invalidate()
923
1010
  if isinstance(event, QueuedMessageInjectedEvent):
@@ -953,6 +1040,14 @@ class TerminalAgentTUI(
953
1040
  }
954
1041
  if isinstance(event, StopEvent):
955
1042
  stop_reason = str(event.reason or "")
1043
+ raw_stop_error = event.metadata.get("error")
1044
+ if raw_stop_error is not None:
1045
+ stop_error = (
1046
+ raw_stop_error
1047
+ if isinstance(raw_stop_error, Exception)
1048
+ else Exception(str(raw_stop_error))
1049
+ )
1050
+ stop_reason = "error"
956
1051
  if event.reason == "shutdown_request":
957
1052
  shutdown_recipients = event.metadata.get("shutdown_recipients", [])
958
1053
  if shutdown_recipients:
@@ -968,6 +1063,15 @@ class TerminalAgentTUI(
968
1063
  waiting_for_input = True
969
1064
  if new_questions is not None:
970
1065
  questions = new_questions
1066
+ if stop_error is not None:
1067
+ from comate_cli.terminal_agent.error_display import format_error
1068
+
1069
+ message, transient_summary, severity = format_error(stop_error)
1070
+ self._renderer.append_system_message(message, severity=severity)
1071
+ self._status_bar.show_transient(
1072
+ transient_summary,
1073
+ severity=severity,
1074
+ )
971
1075
  self._refresh_layers()
972
1076
  if isinstance(event, StopEvent):
973
1077
  background_turn = self._background_turn_source is not None
@@ -1487,6 +1591,9 @@ class TerminalAgentTUI(
1487
1591
 
1488
1592
  # Commands → SlashCommandRegistry with namespace prefix
1489
1593
  for cmd in plugin.commands:
1594
+ if cmd.name in getattr(self, "_skill_slash_command_names", set()):
1595
+ self._slash_registry.unregister(cmd.name)
1596
+ self._skill_slash_command_names.discard(cmd.name)
1490
1597
  spec = SlashCommandSpec(
1491
1598
  name=cmd.name, # already namespaced: "plugin-name:cmd-name"
1492
1599
  description=cmd.description,
@@ -1544,8 +1651,10 @@ class TerminalAgentTUI(
1544
1651
  if any(p.agents for p in plugins):
1545
1652
  rebuild_agent_tool(agent)
1546
1653
 
1654
+ if any(p.skills for p in plugins):
1655
+ self._sync_skill_slash_commands()
1547
1656
  # Refresh tab-completion so new plugin commands appear
1548
- if any(p.commands for p in plugins):
1657
+ elif any(p.commands for p in plugins):
1549
1658
  self._slash_completer.update_commands(self._slash_registry.command_specs())
1550
1659
 
1551
1660
  def _clear_plugin_resources(self) -> None:
@@ -1583,10 +1692,6 @@ class TerminalAgentTUI(
1583
1692
  self._custom_slash_commands.pop(cmd_name, None)
1584
1693
  self._plugin_command_names = set()
1585
1694
 
1586
- # Refresh tab-completion so removed commands disappear
1587
- if had_commands:
1588
- self._slash_completer.update_commands(self._slash_registry.command_specs())
1589
-
1590
1695
  # Remove plugin MCP servers from frozen config
1591
1696
  has_mcp = any(p.mcp_servers for p in loaded)
1592
1697
  if has_mcp and agent.config.mcp_servers:
@@ -1610,3 +1715,5 @@ class TerminalAgentTUI(
1610
1715
  rebuild_skill_tool(agent)
1611
1716
  if had_agents:
1612
1717
  rebuild_agent_tool(agent)
1718
+ if had_skills or had_commands:
1719
+ self._sync_skill_slash_commands()
@@ -4,14 +4,16 @@ import asyncio
4
4
  import logging
5
5
  import time
6
6
  from pathlib import Path
7
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
9
 
10
10
  from prompt_toolkit.document import Document
11
11
 
12
12
  from comate_agent_sdk.agent.llm_levels import ALL_LEVELS
13
+ from comate_agent_sdk.context import TokenCounter
13
14
  from comate_agent_sdk.context.items import ItemType
14
15
  from comate_agent_sdk.llm.messages import UserMessage
16
+ from comate_agent_sdk.skill.execution import invoke_skill_by_name
15
17
 
16
18
  from comate_cli.terminal_agent.figures import (
17
19
  CHECK_MARK,
@@ -30,6 +32,9 @@ from comate_cli.terminal_agent.tui_parts.ui_mode import UIMode
30
32
 
31
33
  logger = logging.getLogger(__name__)
32
34
 
35
+ if TYPE_CHECKING:
36
+ from comate_cli.terminal_agent.plugins.plugin_picker import PluginPickerAction
37
+
33
38
 
34
39
  class CommandsMixin:
35
40
  def _begin_llm_long_task(self) -> None:
@@ -165,7 +170,10 @@ class CommandsMixin:
165
170
  terminal_width = None
166
171
 
167
172
  def on_confirm(value: str) -> None:
168
- self._prefill_user_input(value)
173
+ self._prefill_user_input(
174
+ f"/{value} ",
175
+ preserve_trailing_space=True,
176
+ )
169
177
  self._refresh_layers()
170
178
 
171
179
  def on_cancel() -> None:
@@ -739,30 +747,86 @@ class CommandsMixin:
739
747
 
740
748
  def _active_session_skills(self) -> list[dict[str, str]]:
741
749
  agent = getattr(self._session, "_agent", None)
742
- options = getattr(agent, "options", None)
743
- loaded_skills = list(getattr(options, "skills", []) or [])
750
+ runtime_state = getattr(agent, "_runtime_state", None)
751
+ loaded_skills = list(getattr(runtime_state, "skills", []) or [])
752
+ registered_names = set(getattr(self, "_skill_slash_command_names", set()))
753
+ plugin_namespaces = {
754
+ str(getattr(plugin, "namespace", "")).strip()
755
+ for plugin in getattr(self, "_loaded_plugins", []) or []
756
+ }
757
+ plugin_namespaces.discard("")
758
+ token_counter = TokenCounter()
744
759
  seen_names: set[str] = set()
745
760
  active: list[dict[str, str]] = []
746
761
 
747
762
  for skill in loaded_skills:
748
- if bool(getattr(skill, "disable_model_invocation", False)):
749
- continue
750
-
751
763
  name = str(getattr(skill, "name", "")).strip()
752
- if not name or name in seen_names:
764
+ if not name or name in seen_names or name not in registered_names:
753
765
  continue
754
766
 
755
- description = str(getattr(skill, "description", "")).strip()
767
+ description = str(getattr(skill, "description", "") or "").strip()
768
+ when_to_use = str(getattr(skill, "when_to_use", "") or "").strip()
769
+ frontmatter_text = " ".join(
770
+ part for part in (name, description, when_to_use) if part
771
+ )
772
+ token_count = token_counter.count(frontmatter_text)
773
+ source = (
774
+ "plugin"
775
+ if any(name.startswith(f"{namespace}:") for namespace in plugin_namespaces)
776
+ else "user"
777
+ )
756
778
  active.append(
757
779
  {
758
780
  "name": name,
759
- "description": description or "(No description)",
781
+ "description": f"· {source} · ~{token_count} tok",
760
782
  }
761
783
  )
762
784
  seen_names.add(name)
763
785
 
764
786
  return active
765
787
 
788
+ async def _slash_skill(self, *, skill_name: str, args: str) -> None:
789
+ normalized_args = args.strip()
790
+ result = await invoke_skill_by_name(
791
+ self._session._agent,
792
+ skill_name,
793
+ args=normalized_args or None,
794
+ invocation_source="user_slash",
795
+ flush=False,
796
+ )
797
+ if not result.success:
798
+ self._renderer.append_system_message(result.message, severity="error")
799
+ return
800
+ self._session._agent._context.add_message(
801
+ UserMessage(
802
+ content=self._format_skill_slash_metadata(
803
+ skill_name=skill_name,
804
+ args=normalized_args,
805
+ ),
806
+ is_meta=True,
807
+ )
808
+ )
809
+ self._session._agent._context.flush_pending_skill_items()
810
+
811
+ display_text = f"/{skill_name}"
812
+ if normalized_args:
813
+ display_text = f"{display_text} {normalized_args}"
814
+
815
+ model_prompt = "The requested skill has already been loaded. Continue using the injected skill instructions."
816
+ if normalized_args:
817
+ model_prompt = f"{model_prompt}\n\nArguments: {normalized_args}"
818
+ await self._submit_user_message(model_prompt, display_text=display_text)
819
+
820
+ @staticmethod
821
+ def _format_skill_slash_metadata(*, skill_name: str, args: str) -> str:
822
+ parts = [
823
+ f"<command-message>{skill_name}</command-message>",
824
+ f"<command-name>/{skill_name}</command-name>",
825
+ ]
826
+ if args:
827
+ parts.append(f"<command-args>{args}</command-args>")
828
+ return "\n".join(parts)
829
+
766
830
  async def _slash_custom(self, *, command_name: str, args: str) -> None:
767
831
  command = self._custom_slash_commands.get(command_name)
768
832
  if command is None:
@@ -1336,9 +1400,14 @@ class CommandsMixin:
1336
1400
  return content_text
1337
1401
  return fallback.strip()
1338
1402
 
1339
- def _prefill_user_input(self, text: str) -> None:
1340
- normalized = str(text).strip()
1341
- if not normalized:
1403
+ def _prefill_user_input(
1404
+ self,
1405
+ text: str,
1406
+ *,
1407
+ preserve_trailing_space: bool = False,
1408
+ ) -> None:
1409
+ normalized = str(text).rstrip("\r\n") if preserve_trailing_space else str(text).strip()
1410
+ if not normalized.strip():
1342
1411
  return
1343
1412
  self._clear_paste_state()
1344
1413
  self._last_input_len = len(normalized)
@@ -13,6 +13,61 @@ from comate_cli.terminal_agent.tui_parts.ui_mode import UIMode
13
13
 
14
14
 
15
15
  class KeyBindingsMixin:
16
+ def _pop_human_queue_to_input(self, buffer) -> bool: # type: ignore[no-untyped-def]
17
+ if str(getattr(buffer, "text", "") or "").strip():
18
+ return False
19
+
20
+ peek_queue = getattr(self._session, "peek_queue", None)
21
+ queued_messages = tuple(peek_queue()) if callable(peek_queue) else ()
22
+ human_messages = [
23
+ message
24
+ for message in queued_messages
25
+ if getattr(message, "origin", None) == MessageOrigin.HUMAN
26
+ ]
27
+ if not human_messages:
28
+ return False
29
+
30
+ def _editable_text(message) -> str: # type: ignore[no-untyped-def]
31
+ content = getattr(message, "content", "")
32
+ if isinstance(content, str):
33
+ return content
34
+ if isinstance(content, list):
35
+ pieces: list[str] = []
36
+ for part in content:
37
+ if isinstance(part, dict):
38
+ part_type = str(part.get("type", ""))
39
+ if part_type == "text":
40
+ pieces.append(str(part.get("text", "")))
41
+ elif "image" in part_type:
42
+ pieces.append("[image]")
43
+ else:
44
+ pieces.append(str(part))
45
+ return "\n".join(piece for piece in pieces if piece)
46
+ return str(content)
47
+
48
+ text = "\n\n".join(
49
+ piece
50
+ for piece in (_editable_text(message) for message in human_messages)
51
+ if piece
52
+ )
53
+ buffer.text = text
54
+ buffer.cursor_position = len(text)
55
+ message_ids = tuple(
56
+ str(getattr(message, "message_id"))
57
+ for message in human_messages
58
+ )
59
+ cancel_many = getattr(self._session, "cancel_many", None)
60
+ if callable(cancel_many):
61
+ cancel_many(message_ids)
62
+ queued_display = getattr(self, "_queued_display_by_message_id", None)
63
+ if isinstance(queued_display, dict):
64
+ for message_id in message_ids:
65
+ queued_display.pop(message_id, None)
66
+ self._esc_press_count = 0
67
+ self._esc_last_pressed_at = time.monotonic()
68
+ self._invalidate()
69
+ return True
70
+
16
71
  def _build_key_bindings(self) -> KeyBindings:
17
72
  bindings = KeyBindings()
18
73
  normal_mode = Condition(lambda: self._ui_mode == UIMode.NORMAL)
@@ -341,6 +396,8 @@ class KeyBindingsMixin:
341
396
  if self._move_cursor_visual(buffer, backward=True):
342
397
  self._invalidate()
343
398
  return
399
+ if self._pop_human_queue_to_input(buffer):
400
+ return
344
401
  if self._should_handle_history():
345
402
  buffer.auto_up(count=1)
346
403
 
@@ -377,53 +434,7 @@ class KeyBindingsMixin:
377
434
  self._esc_last_pressed_at = now
378
435
  return
379
436
 
380
- peek_queue = getattr(self._session, "peek_queue", None)
381
- queued_messages = tuple(peek_queue()) if callable(peek_queue) else ()
382
- human_messages = [
383
- message
384
- for message in queued_messages
385
- if getattr(message, "origin", None) == MessageOrigin.HUMAN
386
- ]
387
- if human_messages:
388
- def _editable_text(message) -> str: # type: ignore[no-untyped-def]
389
- content = getattr(message, "content", "")
390
- if isinstance(content, str):
391
- return content
392
- if isinstance(content, list):
393
- pieces: list[str] = []
394
- for part in content:
395
- if isinstance(part, dict):
396
- part_type = str(part.get("type", ""))
397
- if part_type == "text":
398
- pieces.append(str(part.get("text", "")))
399
- elif "image" in part_type:
400
- pieces.append("[image]")
401
- else:
402
- pieces.append(str(part))
403
- return "\n".join(piece for piece in pieces if piece)
404
- return str(content)
405
-
406
- text = "\n\n".join(
407
- piece
408
- for piece in (_editable_text(message) for message in human_messages)
409
- if piece
410
- )
411
- buffer.text = text
412
- buffer.cursor_position = len(text)
413
- message_ids = tuple(
414
- str(getattr(message, "message_id"))
415
- for message in human_messages
416
- )
417
- cancel_many = getattr(self._session, "cancel_many", None)
418
- if callable(cancel_many):
419
- cancel_many(message_ids)
420
- queued_display = getattr(self, "_queued_display_by_message_id", None)
421
- if isinstance(queued_display, dict):
422
- for message_id in message_ids:
423
- queued_display.pop(message_id, None)
424
- self._esc_press_count = 0
425
- self._esc_last_pressed_at = now
426
- self._invalidate()
437
+ if self._pop_human_queue_to_input(buffer):
427
438
  return
428
439
 
429
440
  if now - self._esc_last_pressed_at > self._esc_clear_window_seconds:
@@ -6,7 +6,7 @@ from typing import Any, Callable, Literal
6
6
  from comate_cli.terminal_agent.slash_commands import SlashCommandSpec
7
7
 
8
8
 
9
- SlashCommandSource = Literal["builtin", "custom"]
9
+ SlashCommandSource = Literal["builtin", "custom", "skill"]
10
10
 
11
11
 
12
12
  @dataclass(frozen=True, slots=True)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comate-cli"
7
- version = "0.5.5"
7
+ version = "0.5.7"
8
8
  description = "Comate terminal CLI built on comate-agent-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -40,6 +40,16 @@ class TestQ11Fallback(unittest.TestCase):
40
40
  # 兜底走 _append_assistant_text → buffer 累
41
41
  self.assertEqual(renderer._assistant_buffer, "legacy hello")
42
42
 
43
+ def test_provider_error_stop_does_not_add_interrupted_message(self) -> None:
44
+ renderer = EventRenderer()
45
+ renderer.start_turn()
46
+ renderer.handle_event(
47
+ StopEvent(reason="interrupted", metadata={"error": Exception("Connection refused")})
48
+ )
49
+
50
+ texts = [entry.text for entry in renderer.history_entries()]
51
+ self.assertNotIn("Current task interrupted.", texts)
52
+
43
53
  def test_thinking_event_no_op_when_delta_received(self) -> None:
44
54
  renderer = EventRenderer()
45
55
  renderer.start_turn()
@@ -124,6 +124,40 @@ class TestSelectionMenu(unittest.TestCase):
124
124
  self.assertIn("Helps users", lines[0])
125
125
  self.assertNotIn(" Helps users", lines[0])
126
126
 
127
+ def test_inline_layout_styles_name_and_description_separately(self) -> None:
128
+ ui = SelectionMenuUI()
129
+ ok = ui.set_options(
130
+ title="menu",
131
+ options=[
132
+ {
133
+ "value": "find-skills",
134
+ "label": "find-skills",
135
+ "description": "· user · ~12 tok",
136
+ }
137
+ ],
138
+ layout_mode="inline",
139
+ line_wrap=False,
140
+ inline_total_width=56,
141
+ inline_name_min_width=12,
142
+ inline_name_max_width=16,
143
+ )
144
+ self.assertTrue(ok)
145
+
146
+ fragments = ui._options_fragments()
147
+ self.assertTrue(
148
+ any(
149
+ style == "class:selection.option.selected" and "find-skills" in text
150
+ for style, text in fragments
151
+ )
152
+ )
153
+ self.assertTrue(
154
+ any(
155
+ style == "class:selection.description.selected"
156
+ and "· user · ~12 tok" in text
157
+ for style, text in fragments
158
+ )
159
+ )
160
+
127
161
  def test_context_lines_render_before_options(self) -> None:
128
162
  ui = SelectionMenuUI()
129
163
  ok = ui.set_options(