comate-cli 0.5.5__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 (139) hide show
  1. {comate_cli-0.5.5 → comate_cli-0.5.6}/PKG-INFO +1 -1
  2. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/event_renderer.py +1 -1
  3. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/selection_menu.py +17 -13
  4. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui.py +108 -5
  5. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/commands.py +82 -13
  6. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/key_bindings.py +58 -47
  7. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +1 -1
  8. {comate_cli-0.5.5 → comate_cli-0.5.6}/pyproject.toml +1 -1
  9. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_event_renderer_streaming.py +10 -0
  10. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_selection_menu.py +34 -0
  11. comate_cli-0.5.6/tests/test_skills_slash_command.py +386 -0
  12. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_esc_queue.py +40 -1
  13. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_queue_sdk_source.py +68 -1
  14. {comate_cli-0.5.5 → comate_cli-0.5.6}/uv.lock +2 -2
  15. comate_cli-0.5.5/tests/test_skills_slash_command.py +0 -205
  16. {comate_cli-0.5.5 → comate_cli-0.5.6}/.gitignore +0 -0
  17. {comate_cli-0.5.5 → comate_cli-0.5.6}/README.md +0 -0
  18. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/__init__.py +0 -0
  19. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/__main__.py +0 -0
  20. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/main.py +0 -0
  21. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/mcp_cli.py +0 -0
  22. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/__init__.py +0 -0
  23. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/animations.py +0 -0
  24. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/app.py +0 -0
  25. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/assistant_render.py +0 -0
  26. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/codenames.py +0 -0
  27. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  28. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/env_utils.py +0 -0
  29. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/error_display.py +0 -0
  30. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/figures.py +0 -0
  31. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  32. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/history_printer.py +0 -0
  33. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/input_geometry.py +0 -0
  34. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  35. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  36. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/logo.py +0 -0
  37. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/markdown_render.py +0 -0
  38. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/mention_completer.py +0 -0
  39. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/message_style.py +0 -0
  40. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/models.py +0 -0
  41. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/path_context_hint.py +0 -0
  42. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/__init__.py +0 -0
  43. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/__init__.py +0 -0
  44. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/detail_view.py +0 -0
  45. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/plugin_list.py +0 -0
  46. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/search_box.py +0 -0
  47. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/components/tab_bar.py +0 -0
  48. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/marketplace_install_view.py +0 -0
  49. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/plugin_picker.py +0 -0
  50. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/__init__.py +0 -0
  51. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/discover_tab.py +0 -0
  52. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/errors_tab.py +0 -0
  53. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/installed_tab.py +0 -0
  54. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/plugins/tabs/marketplaces_tab.py +0 -0
  55. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/preflight.py +0 -0
  56. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/question_view.py +0 -0
  57. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/resume_selector.py +0 -0
  58. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/rewind_store.py +0 -0
  59. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  60. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  61. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/session_view.py +0 -0
  62. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/slash_commands.py +0 -0
  63. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/startup.py +0 -0
  64. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/status_bar.py +0 -0
  65. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/text_effects.py +0 -0
  66. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tips.py +0 -0
  67. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tool_view.py +0 -0
  68. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  69. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  70. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  71. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/mcp_connecting_view.py +0 -0
  72. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
  73. {comate_cli-0.5.5 → comate_cli-0.5.6}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  74. {comate_cli-0.5.5 → comate_cli-0.5.6}/docs/superpowers/plans/2026-04-03-phrase-shuffle.md +0 -0
  75. {comate_cli-0.5.5 → comate_cli-0.5.6}/docs/superpowers/specs/2026-04-01-conditional-diff-subtitle-design.md +0 -0
  76. {comate_cli-0.5.5 → comate_cli-0.5.6}/docs/superpowers/specs/2026-04-03-phrase-shuffle-design.md +0 -0
  77. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/conftest.py +0 -0
  78. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_animator_shuffle.py +0 -0
  79. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_app_mcp_preload.py +0 -0
  80. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_app_preflight_gate.py +0 -0
  81. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_app_print_mode.py +0 -0
  82. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_app_shutdown.py +0 -0
  83. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_app_usage_line.py +0 -0
  84. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_cli_project_root.py +0 -0
  85. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_compact_command_semantics.py +0 -0
  86. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_completion_context_activation.py +0 -0
  87. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_completion_status_panel.py +0 -0
  88. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_context_command.py +0 -0
  89. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_custom_slash_commands.py +0 -0
  90. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_discover_tab.py +0 -0
  91. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_errors_tab.py +0 -0
  92. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_event_renderer.py +0 -0
  93. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_event_renderer_boundary.py +0 -0
  94. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_event_renderer_e2e.py +0 -0
  95. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_format_error.py +0 -0
  96. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_handle_error.py +0 -0
  97. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_history_printer.py +0 -0
  98. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_history_sync.py +0 -0
  99. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_input_behavior.py +0 -0
  100. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_input_history.py +0 -0
  101. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_installed_tab.py +0 -0
  102. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_interrupt_exit_semantics.py +0 -0
  103. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_layout_coordinator.py +0 -0
  104. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_logging_adapter.py +0 -0
  105. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_logo.py +0 -0
  106. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_main_args.py +0 -0
  107. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_markdown_render.py +0 -0
  108. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_marketplaces_tab.py +0 -0
  109. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_mcp_cli.py +0 -0
  110. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_mcp_slash_command.py +0 -0
  111. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_mention_completer.py +0 -0
  112. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_path_context_hint.py +0 -0
  113. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_plugin_slash_commands.py +0 -0
  114. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_plugin_tui_components.py +0 -0
  115. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_preflight.py +0 -0
  116. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_preflight_copilot.py +0 -0
  117. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_question_key_bindings.py +0 -0
  118. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_question_view.py +0 -0
  119. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_resume_selector.py +0 -0
  120. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_rewind_command_semantics.py +0 -0
  121. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_rewind_store.py +0 -0
  122. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_rpc_protocol.py +0 -0
  123. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_rpc_stdio_bridge.py +0 -0
  124. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_slash_argument_hint.py +0 -0
  125. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_slash_completer.py +0 -0
  126. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_slash_registry.py +0 -0
  127. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_status_bar.py +0 -0
  128. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_status_bar_transient.py +0 -0
  129. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_task_panel_format.py +0 -0
  130. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_task_panel_key_bindings.py +0 -0
  131. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_task_panel_rendering.py +0 -0
  132. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_task_poll.py +0 -0
  133. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tool_view.py +0 -0
  134. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_elapsed_status.py +0 -0
  135. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_mcp_init_gate.py +0 -0
  136. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_paste_placeholder.py +0 -0
  137. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_queue_preview.py +0 -0
  138. {comate_cli-0.5.5 → comate_cli-0.5.6}/tests/test_tui_split_invariance.py +0 -0
  139. {comate_cli-0.5.5 → 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.5
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
@@ -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)
@@ -222,6 +222,7 @@ class TerminalAgentTUI(
222
222
  self._renderer._show_thinking_cb = lambda: self._show_thinking
223
223
 
224
224
  self._custom_slash_commands: dict[str, CustomSlashCommand] = {}
225
+ self._skill_slash_command_names: set[str] = set()
225
226
  self._slash_registry = SlashCommandRegistry()
226
227
  self._build_slash_registry()
227
228
  self._slash_completer = SlashCommandCompleter(
@@ -237,6 +238,7 @@ class TerminalAgentTUI(
237
238
  deduplicate=True,
238
239
  )
239
240
  )
241
+ self._sync_skill_slash_commands()
240
242
  self._loading_frame = 0
241
243
  self._fallback_loading_phrase = (
242
244
  random.choice(DEFAULT_STATUS_PHRASES)
@@ -719,6 +721,56 @@ class TerminalAgentTUI(
719
721
  argument_hint=command.argument_hint,
720
722
  )
721
723
 
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()
729
+
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
+
735
+ for skill in skills:
736
+ if not bool(getattr(skill, "user_invocable", True)):
737
+ continue
738
+
739
+ name = str(getattr(skill, "name", "")).strip()
740
+ if not name or name in seen_names:
741
+ continue
742
+ seen_names.add(name)
743
+
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
750
+
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
+
722
774
  def _resolve_slash_spec(self, name: str) -> SlashCommandSpec | None:
723
775
  entry = self._slash_registry.resolve(name)
724
776
  if entry is None:
@@ -904,6 +956,7 @@ class TerminalAgentTUI(
904
956
  async for event in self._session.events():
905
957
  if self._closing:
906
958
  break
959
+ stop_error: Exception | None = None
907
960
  self._maybe_cancel_tool_result_flash(event)
908
961
  await self._animation_controller.on_event(event)
909
962
  if isinstance(event, SessionInitEvent):
@@ -918,6 +971,36 @@ class TerminalAgentTUI(
918
971
  str(message_id),
919
972
  None,
920
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)
921
1004
  self._refresh_layers()
922
1005
  self._invalidate()
923
1006
  if isinstance(event, QueuedMessageInjectedEvent):
@@ -953,6 +1036,14 @@ class TerminalAgentTUI(
953
1036
  }
954
1037
  if isinstance(event, StopEvent):
955
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"
956
1047
  if event.reason == "shutdown_request":
957
1048
  shutdown_recipients = event.metadata.get("shutdown_recipients", [])
958
1049
  if shutdown_recipients:
@@ -968,6 +1059,15 @@ class TerminalAgentTUI(
968
1059
  waiting_for_input = True
969
1060
  if new_questions is not None:
970
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
+ )
971
1071
  self._refresh_layers()
972
1072
  if isinstance(event, StopEvent):
973
1073
  background_turn = self._background_turn_source is not None
@@ -1487,6 +1587,9 @@ class TerminalAgentTUI(
1487
1587
 
1488
1588
  # Commands → SlashCommandRegistry with namespace prefix
1489
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)
1490
1593
  spec = SlashCommandSpec(
1491
1594
  name=cmd.name, # already namespaced: "plugin-name:cmd-name"
1492
1595
  description=cmd.description,
@@ -1544,8 +1647,10 @@ class TerminalAgentTUI(
1544
1647
  if any(p.agents for p in plugins):
1545
1648
  rebuild_agent_tool(agent)
1546
1649
 
1650
+ if any(p.skills for p in plugins):
1651
+ self._sync_skill_slash_commands()
1547
1652
  # Refresh tab-completion so new plugin commands appear
1548
- if any(p.commands for p in plugins):
1653
+ elif any(p.commands for p in plugins):
1549
1654
  self._slash_completer.update_commands(self._slash_registry.command_specs())
1550
1655
 
1551
1656
  def _clear_plugin_resources(self) -> None:
@@ -1583,10 +1688,6 @@ class TerminalAgentTUI(
1583
1688
  self._custom_slash_commands.pop(cmd_name, None)
1584
1689
  self._plugin_command_names = set()
1585
1690
 
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
1691
  # Remove plugin MCP servers from frozen config
1591
1692
  has_mcp = any(p.mcp_servers for p in loaded)
1592
1693
  if has_mcp and agent.config.mcp_servers:
@@ -1610,3 +1711,5 @@ class TerminalAgentTUI(
1610
1711
  rebuild_skill_tool(agent)
1611
1712
  if had_agents:
1612
1713
  rebuild_agent_tool(agent)
1714
+ if had_skills or had_commands:
1715
+ 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.6"
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(