claude_swarm 1.0.1 → 1.0.4

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 (267) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/release.md +1 -1
  3. data/.claude/hooks/lint-code-files.rb +65 -0
  4. data/.rubocop.yml +22 -2
  5. data/CHANGELOG.md +14 -1
  6. data/CLAUDE.md +1 -1
  7. data/CONTRIBUTING.md +69 -0
  8. data/README.md +27 -2
  9. data/Rakefile +71 -3
  10. data/analyze_coverage.rb +94 -0
  11. data/docs/v2/CHANGELOG.swarm_cli.md +43 -0
  12. data/docs/v2/CHANGELOG.swarm_memory.md +379 -0
  13. data/docs/v2/CHANGELOG.swarm_sdk.md +362 -0
  14. data/docs/v2/README.md +308 -0
  15. data/docs/v2/guides/claude-code-agents.md +262 -0
  16. data/docs/v2/guides/complete-tutorial.md +3088 -0
  17. data/docs/v2/guides/getting-started.md +1456 -0
  18. data/docs/v2/guides/memory-adapters.md +998 -0
  19. data/docs/v2/guides/plugins.md +816 -0
  20. data/docs/v2/guides/quick-start-cli.md +1745 -0
  21. data/docs/v2/guides/rails-integration.md +1902 -0
  22. data/docs/v2/guides/swarm-memory.md +599 -0
  23. data/docs/v2/reference/cli.md +729 -0
  24. data/docs/v2/reference/ruby-dsl.md +2154 -0
  25. data/docs/v2/reference/yaml.md +1835 -0
  26. data/docs-team-swarm.yml +2222 -0
  27. data/examples/learning-assistant/assistant.md +7 -0
  28. data/examples/learning-assistant/example-memories/concept-example.md +90 -0
  29. data/examples/learning-assistant/example-memories/experience-example.md +66 -0
  30. data/examples/learning-assistant/example-memories/fact-example.md +76 -0
  31. data/examples/learning-assistant/example-memories/memory-index.md +78 -0
  32. data/examples/learning-assistant/example-memories/skill-example.md +168 -0
  33. data/examples/learning-assistant/learning_assistant.rb +34 -0
  34. data/examples/learning-assistant/learning_assistant.yml +20 -0
  35. data/examples/v2/dsl/01_basic.rb +44 -0
  36. data/examples/v2/dsl/02_core_parameters.rb +59 -0
  37. data/examples/v2/dsl/03_capabilities.rb +71 -0
  38. data/examples/v2/dsl/04_llm_parameters.rb +56 -0
  39. data/examples/v2/dsl/05_advanced_flags.rb +73 -0
  40. data/examples/v2/dsl/06_permissions.rb +80 -0
  41. data/examples/v2/dsl/07_mcp_server.rb +62 -0
  42. data/examples/v2/dsl/08_swarm_hooks.rb +53 -0
  43. data/examples/v2/dsl/09_agent_hooks.rb +67 -0
  44. data/examples/v2/dsl/10_all_agents_hooks.rb +67 -0
  45. data/examples/v2/dsl/11_delegation.rb +60 -0
  46. data/examples/v2/dsl/12_complete_integration.rb +137 -0
  47. data/examples/v2/file_tools_swarm.yml +102 -0
  48. data/examples/v2/hooks/01_basic_hooks.rb +133 -0
  49. data/examples/v2/hooks/02_usage_tracking.rb +201 -0
  50. data/examples/v2/hooks/03_production_monitoring.rb +429 -0
  51. data/examples/v2/hooks/agent_stop_exit_0.yml +21 -0
  52. data/examples/v2/hooks/agent_stop_exit_1.yml +21 -0
  53. data/examples/v2/hooks/agent_stop_exit_2.yml +26 -0
  54. data/examples/v2/hooks/multiple_hooks_all_pass.yml +37 -0
  55. data/examples/v2/hooks/multiple_hooks_first_fails.yml +37 -0
  56. data/examples/v2/hooks/multiple_hooks_second_fails.yml +37 -0
  57. data/examples/v2/hooks/multiple_hooks_warnings.yml +37 -0
  58. data/examples/v2/hooks/post_tool_use_exit_0.yml +24 -0
  59. data/examples/v2/hooks/post_tool_use_exit_1.yml +24 -0
  60. data/examples/v2/hooks/post_tool_use_exit_2.yml +24 -0
  61. data/examples/v2/hooks/post_tool_use_multi_matcher_exit_0.yml +26 -0
  62. data/examples/v2/hooks/post_tool_use_multi_matcher_exit_1.yml +26 -0
  63. data/examples/v2/hooks/post_tool_use_multi_matcher_exit_2.yml +26 -0
  64. data/examples/v2/hooks/pre_tool_use_exit_0.yml +24 -0
  65. data/examples/v2/hooks/pre_tool_use_exit_1.yml +24 -0
  66. data/examples/v2/hooks/pre_tool_use_exit_2.yml +24 -0
  67. data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_0.yml +26 -0
  68. data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_1.yml +26 -0
  69. data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_2.yml +27 -0
  70. data/examples/v2/hooks/swarm_summary.sh +44 -0
  71. data/examples/v2/hooks/user_prompt_exit_0.yml +21 -0
  72. data/examples/v2/hooks/user_prompt_exit_1.yml +21 -0
  73. data/examples/v2/hooks/user_prompt_exit_2.yml +21 -0
  74. data/examples/v2/hooks/validate_bash.rb +59 -0
  75. data/examples/v2/multi_directory_permissions.yml +221 -0
  76. data/examples/v2/node_context_demo.rb +127 -0
  77. data/examples/v2/node_workflow.rb +173 -0
  78. data/examples/v2/path_resolution_demo.rb +216 -0
  79. data/examples/v2/simple-swarm-v2.rb +90 -0
  80. data/examples/v2/simple-swarm-v2.yml +62 -0
  81. data/examples/v2/swarm.yml +71 -0
  82. data/examples/v2/swarm_with_hooks.yml +61 -0
  83. data/examples/v2/swarm_with_hooks_simple.yml +25 -0
  84. data/examples/v2/think_tool_demo.rb +62 -0
  85. data/exe/swarm +6 -0
  86. data/lib/claude_swarm/claude_mcp_server.rb +0 -6
  87. data/lib/claude_swarm/cli.rb +10 -3
  88. data/lib/claude_swarm/commands/ps.rb +19 -20
  89. data/lib/claude_swarm/commands/show.rb +1 -1
  90. data/lib/claude_swarm/configuration.rb +10 -12
  91. data/lib/claude_swarm/mcp_generator.rb +10 -1
  92. data/lib/claude_swarm/orchestrator.rb +73 -49
  93. data/lib/claude_swarm/system_utils.rb +37 -11
  94. data/lib/claude_swarm/version.rb +1 -1
  95. data/lib/claude_swarm/worktree_manager.rb +1 -0
  96. data/lib/claude_swarm/yaml_loader.rb +22 -0
  97. data/lib/claude_swarm.rb +7 -2
  98. data/lib/swarm_cli/cli.rb +201 -0
  99. data/lib/swarm_cli/command_registry.rb +61 -0
  100. data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
  101. data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
  102. data/lib/swarm_cli/commands/migrate.rb +55 -0
  103. data/lib/swarm_cli/commands/run.rb +173 -0
  104. data/lib/swarm_cli/config_loader.rb +97 -0
  105. data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
  106. data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
  107. data/lib/swarm_cli/interactive_repl.rb +918 -0
  108. data/lib/swarm_cli/mcp_serve_options.rb +44 -0
  109. data/lib/swarm_cli/mcp_tools_options.rb +59 -0
  110. data/lib/swarm_cli/migrate_options.rb +54 -0
  111. data/lib/swarm_cli/migrator.rb +132 -0
  112. data/lib/swarm_cli/options.rb +151 -0
  113. data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
  114. data/lib/swarm_cli/ui/components/content_block.rb +120 -0
  115. data/lib/swarm_cli/ui/components/divider.rb +57 -0
  116. data/lib/swarm_cli/ui/components/panel.rb +62 -0
  117. data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
  118. data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
  119. data/lib/swarm_cli/ui/formatters/number.rb +58 -0
  120. data/lib/swarm_cli/ui/formatters/text.rb +77 -0
  121. data/lib/swarm_cli/ui/formatters/time.rb +73 -0
  122. data/lib/swarm_cli/ui/icons.rb +59 -0
  123. data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
  124. data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
  125. data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
  126. data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
  127. data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
  128. data/lib/swarm_cli/version.rb +5 -0
  129. data/lib/swarm_cli.rb +44 -0
  130. data/lib/swarm_memory/adapters/base.rb +141 -0
  131. data/lib/swarm_memory/adapters/filesystem_adapter.rb +845 -0
  132. data/lib/swarm_memory/chat_extension.rb +34 -0
  133. data/lib/swarm_memory/cli/commands.rb +306 -0
  134. data/lib/swarm_memory/core/entry.rb +37 -0
  135. data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
  136. data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
  137. data/lib/swarm_memory/core/path_normalizer.rb +75 -0
  138. data/lib/swarm_memory/core/semantic_index.rb +244 -0
  139. data/lib/swarm_memory/core/storage.rb +288 -0
  140. data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
  141. data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
  142. data/lib/swarm_memory/dsl/memory_config.rb +113 -0
  143. data/lib/swarm_memory/embeddings/embedder.rb +36 -0
  144. data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
  145. data/lib/swarm_memory/errors.rb +21 -0
  146. data/lib/swarm_memory/integration/cli_registration.rb +30 -0
  147. data/lib/swarm_memory/integration/configuration.rb +43 -0
  148. data/lib/swarm_memory/integration/registration.rb +31 -0
  149. data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
  150. data/lib/swarm_memory/optimization/analyzer.rb +244 -0
  151. data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
  152. data/lib/swarm_memory/prompts/memory.md.erb +109 -0
  153. data/lib/swarm_memory/prompts/memory_assistant.md.erb +181 -0
  154. data/lib/swarm_memory/prompts/memory_researcher.md.erb +281 -0
  155. data/lib/swarm_memory/prompts/memory_retrieval.md.erb +78 -0
  156. data/lib/swarm_memory/search/semantic_search.rb +112 -0
  157. data/lib/swarm_memory/search/text_search.rb +42 -0
  158. data/lib/swarm_memory/search/text_similarity.rb +80 -0
  159. data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
  160. data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
  161. data/lib/swarm_memory/tools/load_skill.rb +313 -0
  162. data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
  163. data/lib/swarm_memory/tools/memory_delete.rb +99 -0
  164. data/lib/swarm_memory/tools/memory_edit.rb +185 -0
  165. data/lib/swarm_memory/tools/memory_glob.rb +160 -0
  166. data/lib/swarm_memory/tools/memory_grep.rb +247 -0
  167. data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
  168. data/lib/swarm_memory/tools/memory_read.rb +123 -0
  169. data/lib/swarm_memory/tools/memory_write.rb +231 -0
  170. data/lib/swarm_memory/utils.rb +50 -0
  171. data/lib/swarm_memory/version.rb +5 -0
  172. data/lib/swarm_memory.rb +166 -0
  173. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  174. data/lib/swarm_sdk/agent/builder.rb +461 -0
  175. data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
  176. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  177. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
  178. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
  179. data/lib/swarm_sdk/agent/chat.rb +1159 -0
  180. data/lib/swarm_sdk/agent/context.rb +112 -0
  181. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  182. data/lib/swarm_sdk/agent/definition.rb +556 -0
  183. data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
  184. data/lib/swarm_sdk/configuration.rb +296 -0
  185. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  186. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  187. data/lib/swarm_sdk/context_compactor.rb +340 -0
  188. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  189. data/lib/swarm_sdk/hooks/context.rb +197 -0
  190. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  191. data/lib/swarm_sdk/hooks/error.rb +29 -0
  192. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  193. data/lib/swarm_sdk/hooks/registry.rb +147 -0
  194. data/lib/swarm_sdk/hooks/result.rb +150 -0
  195. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  196. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  197. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  198. data/lib/swarm_sdk/log_collector.rb +51 -0
  199. data/lib/swarm_sdk/log_stream.rb +69 -0
  200. data/lib/swarm_sdk/markdown_parser.rb +75 -0
  201. data/lib/swarm_sdk/model_aliases.json +5 -0
  202. data/lib/swarm_sdk/models.json +1 -0
  203. data/lib/swarm_sdk/models.rb +120 -0
  204. data/lib/swarm_sdk/node/agent_config.rb +49 -0
  205. data/lib/swarm_sdk/node/builder.rb +439 -0
  206. data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
  207. data/lib/swarm_sdk/node_context.rb +170 -0
  208. data/lib/swarm_sdk/node_orchestrator.rb +384 -0
  209. data/lib/swarm_sdk/permissions/config.rb +239 -0
  210. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  211. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  212. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  213. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  214. data/lib/swarm_sdk/plugin.rb +147 -0
  215. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  216. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
  217. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  218. data/lib/swarm_sdk/result.rb +97 -0
  219. data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
  220. data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
  221. data/lib/swarm_sdk/swarm/builder.rb +586 -0
  222. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  223. data/lib/swarm_sdk/swarm/tool_configurator.rb +419 -0
  224. data/lib/swarm_sdk/swarm.rb +982 -0
  225. data/lib/swarm_sdk/tools/bash.rb +274 -0
  226. data/lib/swarm_sdk/tools/clock.rb +44 -0
  227. data/lib/swarm_sdk/tools/delegate.rb +164 -0
  228. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  229. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  230. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  231. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  232. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  233. data/lib/swarm_sdk/tools/edit.rb +150 -0
  234. data/lib/swarm_sdk/tools/glob.rb +158 -0
  235. data/lib/swarm_sdk/tools/grep.rb +228 -0
  236. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  237. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  238. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  239. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  240. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  241. data/lib/swarm_sdk/tools/read.rb +251 -0
  242. data/lib/swarm_sdk/tools/registry.rb +93 -0
  243. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  244. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  245. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  246. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  247. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  248. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  249. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  250. data/lib/swarm_sdk/tools/think.rb +95 -0
  251. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  252. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  253. data/lib/swarm_sdk/tools/write.rb +117 -0
  254. data/lib/swarm_sdk/utils.rb +50 -0
  255. data/lib/swarm_sdk/version.rb +5 -0
  256. data/lib/swarm_sdk.rb +157 -0
  257. data/llm.v2.txt +13407 -0
  258. data/rubocop/cop/security/no_reflection_methods.rb +47 -0
  259. data/rubocop/cop/security/no_ruby_llm_logger.rb +32 -0
  260. data/swarm_cli.gemspec +57 -0
  261. data/swarm_memory.gemspec +28 -0
  262. data/swarm_sdk.gemspec +41 -0
  263. data/team.yml +1 -1
  264. data/team_full.yml +1875 -0
  265. data/{team_v2.yml → team_sdk.yml} +121 -52
  266. metadata +249 -6
  267. data/EXAMPLES.md +0 -164
@@ -0,0 +1,918 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+ require "tty-spinner"
5
+ require "tty-markdown"
6
+ require "tty-box"
7
+ require "pastel"
8
+ require "async"
9
+ require "async/condition"
10
+
11
+ module SwarmCLI
12
+ # InteractiveREPL provides a professional, interactive terminal interface
13
+ # for conversing with SwarmSDK agents.
14
+ #
15
+ # Features:
16
+ # - Multiline input with intuitive submission (Enter on empty line or Ctrl+D)
17
+ # - Beautiful Markdown rendering for agent responses
18
+ # - Progress indicators during processing
19
+ # - Command system (/help, /exit, /clear, etc.)
20
+ # - Conversation history with context preservation
21
+ # - Professional styling with Pastel and TTY tools
22
+ #
23
+ class InteractiveREPL
24
+ COMMANDS = {
25
+ "/help" => "Show available commands",
26
+ "/clear" => "Clear the lead agent's conversation context",
27
+ "/tools" => "List the lead agent's available tools",
28
+ "/history" => "Show conversation history",
29
+ "/defrag" => "Run memory defragmentation workflow (find and link related entries)",
30
+ "/exit" => "Exit the REPL (or press Ctrl+D)",
31
+ }.freeze
32
+
33
+ # History configuration
34
+ HISTORY_SIZE = 1000
35
+
36
+ class << self
37
+ # Get history file path (can be overridden with SWARM_HISTORY env var)
38
+ def history_file
39
+ ENV["SWARM_HISTORY"] || File.expand_path("~/.swarm/history")
40
+ end
41
+ end
42
+
43
+ def initialize(swarm:, options:, initial_message: nil)
44
+ @swarm = swarm
45
+ @options = options
46
+ @initial_message = initial_message
47
+ @conversation_history = []
48
+ @session_results = [] # Accumulate all results for session summary
49
+ @validation_warnings_shown = false
50
+
51
+ setup_ui_components
52
+ setup_persistent_history
53
+
54
+ # Create formatter for swarm execution output (interactive mode)
55
+ @formatter = Formatters::HumanFormatter.new(
56
+ output: $stdout,
57
+ quiet: options.quiet?,
58
+ truncate: options.truncate?,
59
+ verbose: options.verbose?,
60
+ mode: :interactive,
61
+ )
62
+ end
63
+
64
+ def run
65
+ display_welcome
66
+
67
+ # Emit validation warnings before first prompt
68
+ emit_validation_warnings_before_prompt
69
+
70
+ # Send initial message if provided
71
+ if @initial_message && !@initial_message.empty?
72
+ handle_message(@initial_message)
73
+ end
74
+
75
+ main_loop
76
+ display_goodbye
77
+ display_session_summary
78
+ rescue Interrupt
79
+ puts "\n"
80
+ display_goodbye
81
+ display_session_summary
82
+ exit(130)
83
+ ensure
84
+ # Save history on exit
85
+ save_persistent_history
86
+ end
87
+
88
+ # Execute a message with Ctrl+C cancellation support
89
+ # Public for testing
90
+ #
91
+ # @param input [String] User input to execute
92
+ # @return [SwarmSDK::Result, nil] Result or nil if cancelled
93
+ def execute_with_cancellation(input, &log_callback)
94
+ cancelled = false
95
+ result = nil
96
+
97
+ # Execute in Async block to enable Ctrl+C cancellation
98
+ Async do |task|
99
+ # Use Async::Condition for trap-safe cancellation
100
+ # (Condition#signal uses Thread::Queue which is safe from trap context)
101
+ cancel_condition = Async::Condition.new
102
+
103
+ # Install trap ONLY during execution
104
+ # When Ctrl+C is pressed, signal the condition instead of calling task.stop
105
+ old_trap = trap("INT") do
106
+ cancel_condition.signal(:cancel)
107
+ end
108
+
109
+ begin
110
+ # Execute swarm in async task
111
+ llm_task = task.async do
112
+ @swarm.execute(input, &log_callback)
113
+ end
114
+
115
+ # Monitor task - watches for cancellation signal
116
+ # Must be created AFTER llm_task so it can reference it
117
+ monitor_task = task.async do
118
+ if cancel_condition.wait == :cancel
119
+ cancelled = true
120
+ llm_task.stop
121
+ end
122
+ end
123
+
124
+ result = llm_task.wait
125
+ rescue Async::Stop
126
+ # Task was stopped by Ctrl+C
127
+ cancelled = true
128
+ ensure
129
+ # Clean up monitor task
130
+ monitor_task&.stop if monitor_task&.alive?
131
+
132
+ # CRITICAL: Restore old trap when done
133
+ # This ensures Ctrl+C at the prompt still exits the REPL
134
+ trap("INT", old_trap)
135
+ end
136
+ end.wait
137
+
138
+ cancelled ? nil : result
139
+ end
140
+
141
+ # Handle slash commands
142
+ # Public for testing
143
+ #
144
+ # @param input [String] Command input (e.g., "/help", "/clear")
145
+ def handle_command(input)
146
+ command = input.split.first.downcase
147
+
148
+ case command
149
+ when "/help"
150
+ display_help
151
+ when "/clear"
152
+ clear_context
153
+ when "/tools"
154
+ list_tools
155
+ when "/history"
156
+ display_history
157
+ when "/defrag"
158
+ defrag_memory
159
+ when "/exit"
160
+ # Break from main loop to trigger session summary
161
+ throw(:exit_repl)
162
+ else
163
+ puts render_error("Unknown command: #{command}")
164
+ puts @colors[:system].call("Type /help for available commands")
165
+ end
166
+ end
167
+
168
+ # Save persistent history to file
169
+ # Public for testing
170
+ #
171
+ # @return [void]
172
+ def save_persistent_history
173
+ history_file = self.class.history_file
174
+ return unless history_file
175
+
176
+ history = Reline::HISTORY.to_a
177
+
178
+ # Limit to configured size
179
+ if HISTORY_SIZE.positive? && history.size > HISTORY_SIZE
180
+ history = history.last(HISTORY_SIZE)
181
+ end
182
+
183
+ # Write with secure permissions (owner read/write only)
184
+ File.open(history_file, "w", 0o600, encoding: Encoding::UTF_8) do |f|
185
+ # Handle multi-line entries by escaping newlines with backslash
186
+ history.each do |entry|
187
+ escaped = entry.scrub.split("\n").join("\\\n")
188
+ f.puts(escaped)
189
+ end
190
+ end
191
+ rescue Errno::EACCES, Errno::ENOENT
192
+ # Can't write history - continue anyway
193
+ nil
194
+ end
195
+
196
+ private
197
+
198
+ def setup_ui_components
199
+ @pastel = Pastel.new(enabled: $stdout.tty?)
200
+
201
+ # Configure Reline for smooth, flicker-free input (like IRB)
202
+ Reline.output = $stdout
203
+ Reline.input = $stdin
204
+
205
+ # Configure tab completion UI colors (Ruby 3.1+)
206
+ configure_completion_ui
207
+
208
+ # Enable automatic completions (show as you type)
209
+ Reline.autocompletion = true
210
+
211
+ # Configure word break characters
212
+ Reline.completer_word_break_characters = " \t\n,;|&"
213
+
214
+ # Disable default autocomplete (uses start_with? filtering)
215
+ Reline.add_dialog_proc(:autocomplete, nil, nil)
216
+
217
+ # Add custom fuzzy completion dialog (bypasses Reline's filtering)
218
+ setup_fuzzy_completion
219
+
220
+ # Rebind Tab to invoke our custom dialog (not the default :complete method)
221
+ config = Reline.core.config
222
+ config.add_default_key_binding_by_keymap(:emacs, [9], :fuzzy_complete)
223
+ config.add_default_key_binding_by_keymap(:vi_insert, [9], :fuzzy_complete)
224
+
225
+ # Configure history size
226
+ Reline.core.config.history_size = HISTORY_SIZE
227
+
228
+ # Setup colors using detached styles for performance
229
+ @colors = {
230
+ prompt: @pastel.bright_cyan.bold.detach,
231
+ user_input: @pastel.white.detach,
232
+ agent_text: @pastel.bright_white.detach,
233
+ agent_label: @pastel.bright_blue.bold.detach,
234
+ success: @pastel.bright_green.detach,
235
+ success_icon: @pastel.bright_green.bold.detach,
236
+ error: @pastel.bright_red.detach,
237
+ error_icon: @pastel.bright_red.bold.detach,
238
+ warning: @pastel.bright_yellow.detach,
239
+ system: @pastel.dim.detach,
240
+ system_bracket: @pastel.bright_black.detach,
241
+ divider: @pastel.bright_black.detach,
242
+ header: @pastel.bright_cyan.bold.detach,
243
+ code: @pastel.bright_magenta.detach,
244
+ }
245
+ end
246
+
247
+ def setup_persistent_history
248
+ history_file = self.class.history_file
249
+
250
+ # Ensure history directory exists
251
+ FileUtils.mkdir_p(File.dirname(history_file))
252
+
253
+ # Load history from file
254
+ return unless File.exist?(history_file)
255
+
256
+ File.open(history_file, "r:UTF-8") do |f|
257
+ f.each_line do |line|
258
+ line = line.chomp
259
+
260
+ # Handle multi-line entries (backslash continuation)
261
+ if Reline::HISTORY.last&.end_with?("\\")
262
+ Reline::HISTORY.last.delete_suffix!("\\")
263
+ Reline::HISTORY.last << "\n" << line
264
+ else
265
+ Reline::HISTORY << line unless line.empty?
266
+ end
267
+ end
268
+ end
269
+ rescue Errno::ENOENT, Errno::EACCES
270
+ # History file doesn't exist or can't be read - that's OK
271
+ nil
272
+ end
273
+
274
+ def display_welcome
275
+ divider = @colors[:divider].call("─" * 60)
276
+
277
+ puts ""
278
+ puts divider
279
+ puts @colors[:header].call("🚀 Swarm CLI Interactive REPL")
280
+ puts divider
281
+ puts ""
282
+ puts @colors[:agent_text].call("Swarm: #{@swarm.name}")
283
+ puts @colors[:system].call("Lead Agent: #{@swarm.lead_agent}")
284
+ puts ""
285
+ puts @colors[:system].call("Type your message and press Enter to submit")
286
+ puts @colors[:system].call("Press Option+Enter (or ESC then Enter) for multi-line input")
287
+ puts @colors[:system].call("Type #{@colors[:code].call("/help")} for commands or #{@colors[:code].call("/exit")} to quit")
288
+ puts ""
289
+ puts divider
290
+ puts ""
291
+ end
292
+
293
+ def main_loop
294
+ catch(:exit_repl) do
295
+ loop do
296
+ input = read_user_input
297
+
298
+ break if input.nil? # Ctrl+D pressed
299
+ next if input.strip.empty?
300
+
301
+ if input.start_with?("/")
302
+ handle_command(input.strip)
303
+ else
304
+ handle_message(input)
305
+ end
306
+
307
+ puts "" # Spacing between interactions
308
+ end
309
+ end
310
+ end
311
+
312
+ def read_user_input
313
+ # Display stats separately (they scroll up naturally)
314
+ display_prompt_stats
315
+
316
+ # Build the prompt indicator with colors
317
+ prompt_indicator = build_prompt_indicator
318
+
319
+ # Use Reline.readmultiline for multi-line input support
320
+ # - Option+ENTER (or ESC+ENTER): Adds a newline, continues editing
321
+ # - Regular ENTER: Always submits immediately
322
+ # Second parameter true = add to history for arrow up/down
323
+ # Block always returns true = ENTER always submits
324
+ input = Reline.readmultiline(prompt_indicator, true) { |_lines| true }
325
+
326
+ return if input.nil? # Ctrl+D returns nil
327
+
328
+ # Strip whitespace from the complete input
329
+ input.strip
330
+ end
331
+
332
+ def display_prompt_stats
333
+ # Only show stats if we have conversation history
334
+ stats = build_prompt_stats
335
+ puts stats if stats && !stats.empty?
336
+ end
337
+
338
+ def build_prompt_indicator
339
+ # Reline supports ANSI colors without flickering!
340
+ # Use your beautiful colored prompt
341
+ @pastel.bright_cyan("You") +
342
+ @pastel.bright_black(" ❯ ")
343
+ end
344
+
345
+ def build_prompt_stats
346
+ return "" if @conversation_history.empty?
347
+
348
+ parts = []
349
+
350
+ # Agent name
351
+ parts << @colors[:agent_label].call(@swarm.lead_agent.to_s)
352
+
353
+ # Message count (user messages only)
354
+ msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
355
+ parts << "#{msg_count} #{msg_count == 1 ? "msg" : "msgs"}"
356
+
357
+ # Get last result stats if available
358
+ if @last_result
359
+ # Token count
360
+ tokens = @last_result.total_tokens
361
+ if tokens > 0
362
+ formatted_tokens = format_number(tokens)
363
+ parts << "#{formatted_tokens} tokens"
364
+ end
365
+
366
+ # Cost
367
+ cost = @last_result.total_cost
368
+ if cost > 0
369
+ formatted_cost = format_cost_value(cost)
370
+ parts << formatted_cost
371
+ end
372
+
373
+ # Context percentage (from last log entry with usage info)
374
+ if @last_context_percentage
375
+ color_method = context_percentage_color(@last_context_percentage)
376
+ colored_pct = @pastel.public_send(color_method, @last_context_percentage)
377
+ parts << "#{colored_pct} context"
378
+ end
379
+ end
380
+
381
+ "[#{parts.join(" • ")}]"
382
+ end
383
+
384
+ def format_number(num)
385
+ if num >= 1_000_000
386
+ "#{(num / 1_000_000.0).round(1)}M"
387
+ elsif num >= 1_000
388
+ "#{(num / 1_000.0).round(1)}K"
389
+ else
390
+ num.to_s
391
+ end
392
+ end
393
+
394
+ def format_cost_value(cost)
395
+ if cost < 0.01
396
+ "$#{format("%.4f", cost)}"
397
+ elsif cost < 1.0
398
+ "$#{format("%.3f", cost)}"
399
+ else
400
+ "$#{format("%.2f", cost)}"
401
+ end
402
+ end
403
+
404
+ def context_percentage_color(percentage_string)
405
+ percentage = percentage_string.to_s.gsub("%", "").to_f
406
+
407
+ if percentage < 50
408
+ :green
409
+ elsif percentage < 80
410
+ :yellow
411
+ else
412
+ :red
413
+ end
414
+ end
415
+
416
+ def handle_message(input)
417
+ # Add to history
418
+ @conversation_history << { role: "user", content: input }
419
+
420
+ puts ""
421
+
422
+ # Execute with cancellation support
423
+ result = execute_with_cancellation(input) do |log_entry|
424
+ # Skip model warnings - already emitted before first prompt
425
+ next if log_entry[:type] == "model_lookup_warning"
426
+
427
+ @formatter.on_log(log_entry)
428
+
429
+ # Track context percentage from usage info
430
+ if log_entry[:usage] && log_entry[:usage][:tokens_used_percentage]
431
+ @last_context_percentage = log_entry[:usage][:tokens_used_percentage]
432
+ end
433
+ end
434
+
435
+ # Handle cancellation (result is nil when cancelled)
436
+ if result.nil?
437
+ # Stop all active spinners
438
+ @formatter.spinner_manager.stop_all
439
+
440
+ puts ""
441
+ puts @colors[:warning].call("✗ Request cancelled by user")
442
+ puts ""
443
+ return
444
+ end
445
+
446
+ # Check for errors
447
+ if result.failure?
448
+ @formatter.on_error(error: result.error, duration: result.duration)
449
+ return
450
+ end
451
+
452
+ # Display success through formatter (minimal in interactive mode)
453
+ @formatter.on_success(result: result)
454
+
455
+ # Store result for prompt stats and session summary
456
+ @last_result = result
457
+ @session_results << result
458
+
459
+ # Add response to history
460
+ @conversation_history << { role: "agent", content: result.content }
461
+ rescue StandardError => e
462
+ @formatter.on_error(error: e)
463
+ end
464
+
465
+ def emit_validation_warnings_before_prompt
466
+ # Setup temporary logging to capture and display warnings
467
+ SwarmSDK::LogCollector.on_log do |log_entry|
468
+ @formatter.on_log(log_entry) if log_entry[:type] == "model_lookup_warning"
469
+ end
470
+
471
+ SwarmSDK::LogStream.emitter = SwarmSDK::LogCollector
472
+
473
+ # Emit validation warnings as log events
474
+ @swarm.emit_validation_warnings
475
+
476
+ # Clean up
477
+ SwarmSDK::LogCollector.reset!
478
+ SwarmSDK::LogStream.reset!
479
+
480
+ # Add spacing if warnings were shown
481
+ puts "" if @swarm.validate.any?
482
+ rescue StandardError
483
+ # Ignore errors during validation emission
484
+ begin
485
+ SwarmSDK::LogCollector.reset!
486
+ rescue
487
+ nil
488
+ end
489
+ begin
490
+ SwarmSDK::LogStream.reset!
491
+ rescue
492
+ nil
493
+ end
494
+ end
495
+
496
+ def display_help
497
+ help_box = TTY::Box.frame(
498
+ @colors[:header].call("Available Commands:"),
499
+ "",
500
+ *COMMANDS.map do |cmd, desc|
501
+ cmd_styled = @colors[:code].call(cmd.ljust(15))
502
+ desc_styled = @colors[:system].call(desc)
503
+ " #{cmd_styled} #{desc_styled}"
504
+ end,
505
+ "",
506
+ @colors[:system].call("Input Tips:"),
507
+ @colors[:system].call(" • Press Enter to submit your message"),
508
+ @colors[:system].call(" • Press Option+Enter (or ESC then Enter) for multi-line input"),
509
+ @colors[:system].call(" • Press Ctrl+C to cancel an ongoing request"),
510
+ @colors[:system].call(" • Press Ctrl+D to exit"),
511
+ @colors[:system].call(" • Use arrow keys for history and editing"),
512
+ @colors[:system].call(" • Type / for commands or @ for file paths"),
513
+ @colors[:system].call(" • Use Shift-Tab to navigate autocomplete menu"),
514
+ border: :light,
515
+ padding: [1, 2],
516
+ align: :left,
517
+ title: { top_left: " HELP " },
518
+ style: {
519
+ border: { fg: :bright_yellow },
520
+ },
521
+ )
522
+
523
+ puts help_box
524
+ end
525
+
526
+ def clear_context
527
+ # Get the lead agent
528
+ lead = @swarm.agent(@swarm.lead_agent)
529
+
530
+ # Clear the agent's conversation history
531
+ lead.reset_messages!
532
+
533
+ # Clear REPL conversation history
534
+ @conversation_history.clear
535
+
536
+ # Display confirmation
537
+ puts ""
538
+ puts @colors[:success].call("✓ Conversation context cleared for #{@swarm.lead_agent}")
539
+ puts @colors[:system].call(" Starting fresh - previous messages removed from context")
540
+ puts ""
541
+ end
542
+
543
+ def list_tools
544
+ # Get the lead agent
545
+ lead = @swarm.agent(@swarm.lead_agent)
546
+
547
+ # Get tools hash (tool_name => tool_instance)
548
+ tools_hash = lead.tools
549
+
550
+ puts ""
551
+ puts @colors[:header].call("Available Tools for #{@swarm.lead_agent}:")
552
+ puts @colors[:divider].call("─" * 60)
553
+ puts ""
554
+
555
+ if tools_hash.empty?
556
+ puts @colors[:system].call("No tools available")
557
+ return
558
+ end
559
+
560
+ # Group tools by category
561
+ memory_tools = []
562
+ standard_tools = []
563
+ delegation_tools = []
564
+ mcp_tools = []
565
+ other_tools = []
566
+
567
+ tools_hash.each_value do |tool|
568
+ tool_name = tool.name
569
+ case tool_name
570
+ when /^Memory/, "LoadSkill"
571
+ memory_tools << tool_name
572
+ when /^DelegateTaskTo/
573
+ delegation_tools << tool_name
574
+ when /^mcp__/
575
+ mcp_tools << tool_name
576
+ when "Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob",
577
+ "TodoWrite", "Think", "Clock", "WebFetch",
578
+ "ScratchpadWrite", "ScratchpadRead", "ScratchpadList"
579
+ standard_tools << tool_name
580
+ else
581
+ other_tools << tool_name
582
+ end
583
+ end
584
+
585
+ # Display tools by category
586
+ if standard_tools.any?
587
+ puts @colors[:agent_label].call("Standard Tools:")
588
+ standard_tools.sort.each do |name|
589
+ puts @colors[:system].call(" • #{name}")
590
+ end
591
+ puts ""
592
+ end
593
+
594
+ if memory_tools.any?
595
+ puts @colors[:agent_label].call("Memory Tools:")
596
+ memory_tools.sort.each do |name|
597
+ puts @colors[:system].call(" • #{name}")
598
+ end
599
+ puts ""
600
+ end
601
+
602
+ if delegation_tools.any?
603
+ puts @colors[:agent_label].call("Delegation Tools:")
604
+ delegation_tools.sort.each do |name|
605
+ puts @colors[:system].call(" • #{name}")
606
+ end
607
+ puts ""
608
+ end
609
+
610
+ if mcp_tools.any?
611
+ puts @colors[:agent_label].call("MCP Tools:")
612
+ mcp_tools.sort.each do |name|
613
+ puts @colors[:system].call(" • #{name}")
614
+ end
615
+ puts ""
616
+ end
617
+
618
+ if other_tools.any?
619
+ puts @colors[:agent_label].call("Other Tools:")
620
+ other_tools.sort.each do |name|
621
+ puts @colors[:system].call(" • #{name}")
622
+ end
623
+ puts ""
624
+ end
625
+
626
+ puts @colors[:divider].call("─" * 60)
627
+ puts @colors[:system].call("Total: #{tools_hash.size} tools")
628
+ puts ""
629
+ end
630
+
631
+ def display_history
632
+ if @conversation_history.empty?
633
+ puts @colors[:system].call("No conversation history yet")
634
+ return
635
+ end
636
+
637
+ puts @colors[:header].call("Conversation History:")
638
+ puts @colors[:divider].call("─" * 60)
639
+ puts ""
640
+
641
+ @conversation_history.each_with_index do |entry, index|
642
+ role_label = if entry[:role] == "user"
643
+ @colors[:prompt].call("User")
644
+ else
645
+ @colors[:agent_label].call("Agent")
646
+ end
647
+
648
+ puts "#{index + 1}. #{role_label}:"
649
+
650
+ # Truncate long messages in history view
651
+ content = entry[:content]
652
+ if content.length > 200
653
+ content = content[0...200] + "..."
654
+ end
655
+
656
+ puts @colors[:system].call(" #{content.gsub("\n", "\n ")}")
657
+ puts ""
658
+ end
659
+
660
+ puts @colors[:divider].call("─" * 60)
661
+ end
662
+
663
+ def defrag_memory
664
+ puts ""
665
+ puts @colors[:header].call("🔧 Memory Defragmentation Workflow")
666
+ puts @colors[:divider].call("─" * 60)
667
+ puts ""
668
+
669
+ # Inject prompt to run find_related then link_related
670
+ prompt = <<~PROMPT.strip
671
+ Run memory defragmentation workflow:
672
+
673
+ 1. First, run MemoryDefrag(action: "find_related") to discover related entries
674
+ 2. Review the results carefully
675
+ 3. Then run MemoryDefrag(action: "link_related", dry_run: false) to create bidirectional links
676
+
677
+ Report what you found and what links were created.
678
+ PROMPT
679
+
680
+ handle_message(prompt)
681
+ end
682
+
683
+ def display_goodbye
684
+ puts ""
685
+ goodbye_text = @colors[:success].call("👋 Goodbye! Thanks for using Swarm CLI")
686
+ puts goodbye_text
687
+ puts ""
688
+ end
689
+
690
+ def display_session_summary
691
+ return if @session_results.empty?
692
+
693
+ # Calculate session totals
694
+ total_tokens = @session_results.sum(&:total_tokens)
695
+ total_cost = @session_results.sum(&:total_cost)
696
+ total_llm_requests = @session_results.sum(&:llm_requests)
697
+ total_tool_calls = @session_results.sum(&:tool_calls_count)
698
+ all_agents = @session_results.flat_map(&:agents_involved).uniq
699
+
700
+ # Get session duration (time from first to last message)
701
+ session_duration = if @session_results.size > 1
702
+ @session_results.map(&:duration).sum
703
+ else
704
+ @session_results.first&.duration || 0
705
+ end
706
+
707
+ # Render session summary
708
+ divider = @colors[:divider].call("─" * 60)
709
+ puts divider
710
+ puts @colors[:header].call("📊 Session Summary")
711
+ puts divider
712
+ puts ""
713
+
714
+ # Message count
715
+ msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
716
+ puts " #{@colors[:agent_label].call("Messages sent:")} #{msg_count}"
717
+
718
+ # Agents used
719
+ if all_agents.any?
720
+ agents_list = all_agents.map { |agent| @colors[:agent_label].call(agent.to_s) }.join(", ")
721
+ puts " #{@colors[:agent_label].call("Agents used:")} #{agents_list}"
722
+ end
723
+
724
+ # LLM requests
725
+ puts " #{@colors[:system].call("LLM Requests:")} #{total_llm_requests}"
726
+
727
+ # Tool calls
728
+ puts " #{@colors[:system].call("Tool Calls:")} #{total_tool_calls}"
729
+
730
+ # Tokens
731
+ formatted_tokens = SwarmCLI::UI::Formatters::Number.format(total_tokens)
732
+ puts " #{@colors[:system].call("Total Tokens:")} #{formatted_tokens}"
733
+
734
+ # Cost (colored)
735
+ formatted_cost = SwarmCLI::UI::Formatters::Cost.format(total_cost, pastel: @pastel)
736
+ puts " #{@colors[:system].call("Total Cost:")} #{formatted_cost}"
737
+
738
+ # Duration
739
+ formatted_duration = SwarmCLI::UI::Formatters::Time.duration(session_duration)
740
+ puts " #{@colors[:system].call("Session Duration:")} #{formatted_duration}"
741
+
742
+ puts ""
743
+ puts divider
744
+ puts ""
745
+ end
746
+
747
+ def render_error(message)
748
+ icon = @colors[:error_icon].call("✗")
749
+ text = @colors[:error].call(message)
750
+ "#{icon} #{text}"
751
+ end
752
+
753
+ def render_system_message(text)
754
+ bracket_open = @colors[:system_bracket].call("[")
755
+ bracket_close = @colors[:system_bracket].call("]")
756
+ content = @colors[:system].call(text)
757
+ "#{bracket_open}#{content}#{bracket_close}"
758
+ end
759
+
760
+ def configure_completion_ui
761
+ # Only configure if Reline::Face is available (Ruby 3.1+)
762
+ return unless defined?(Reline::Face)
763
+
764
+ Reline::Face.config(:completion_dialog) do |conf|
765
+ conf.define(:default, foreground: :white, background: :blue)
766
+ conf.define(:enhanced, foreground: :black, background: :cyan) # Selected item
767
+ conf.define(:scrollbar, foreground: :cyan, background: :blue)
768
+ end
769
+ rescue StandardError
770
+ # Ignore errors if Face configuration fails
771
+ end
772
+
773
+ def setup_fuzzy_completion
774
+ # Capture COMMANDS for use in lambda
775
+ commands = COMMANDS
776
+
777
+ # Capture file completion logic for use in lambda (since lambda runs in different context)
778
+ file_completions = lambda do |target|
779
+ has_at_prefix = target.start_with?("@")
780
+ query = has_at_prefix ? target[1..] : target
781
+
782
+ next Dir.glob("*").sort.first(20) if query.empty?
783
+
784
+ # Find files matching query anywhere in path
785
+ pattern = "**/*#{query}*"
786
+ found = Dir.glob(pattern, File::FNM_CASEFOLD).reject do |path|
787
+ path.split("/").any? { |part| part.start_with?(".") }
788
+ end.sort.first(20)
789
+
790
+ # Add @ prefix if needed
791
+ has_at_prefix ? found.map { |p| "@#{p}" } : found
792
+ end
793
+
794
+ # Custom dialog proc for fuzzy file/command completion
795
+ fuzzy_proc = lambda do
796
+ # State: [pre, target, post, matches, pointer, navigating]
797
+
798
+ # Check if this is a navigation key press
799
+ is_nav_key = key&.match?(dialog.name)
800
+
801
+ # If we were in navigation mode and user typed a regular key (not Tab), exit nav mode
802
+ if !context.empty? && context.size >= 6 && context[5] && !is_nav_key
803
+ context[5] = false # Exit navigation mode
804
+ end
805
+
806
+ # Early check: if user typed and current target has spaces, close dialog
807
+ unless is_nav_key || context.empty?
808
+ _, target_check, = retrieve_completion_block
809
+ if target_check.include?(" ")
810
+ context.clear
811
+ return
812
+ end
813
+ end
814
+
815
+ # Detect if we should recalculate matches
816
+ should_recalculate = if context.empty?
817
+ true # First time - initialize
818
+ elsif is_nav_key
819
+ false # Navigation key - don't recalculate, just cycle
820
+ elsif context.size >= 6 && context[5]
821
+ false # We're in navigation mode - keep matches stable
822
+ else
823
+ true # User typed something - recalculate
824
+ end
825
+
826
+ # Recalculate matches if user typed
827
+ if should_recalculate
828
+ preposing, target, postposing = retrieve_completion_block
829
+
830
+ # Don't show completions if the target itself has spaces
831
+ # (allows "@lib/swarm" in middle of sentence like "check @lib/swarm file")
832
+ return if target.include?(" ")
833
+
834
+ matches = if target.start_with?("/")
835
+ # Command completions
836
+ query = target[1..] || ""
837
+ commands.keys.map(&:to_s).select do |cmd|
838
+ query.empty? || cmd.downcase.include?(query.downcase)
839
+ end.sort
840
+ elsif target.start_with?("@") || target.include?("/")
841
+ # File path completions - use captured lambda
842
+ file_completions.call(target)
843
+ end
844
+
845
+ return if matches.nil? || matches.empty?
846
+
847
+ # Store fresh values - not in navigation mode yet
848
+ context.clear
849
+ context.push(preposing, target, postposing, matches, 0, false)
850
+ end
851
+
852
+ # Use stored values
853
+ stored_pre, _, stored_post, matches, pointer, _ = context
854
+
855
+ # Handle navigation keys
856
+ if is_nav_key
857
+ # Check if Enter was pressed - close dialog without submitting
858
+ # Must check key.char (not method_symbol, which is :fuzzy_complete when trapped)
859
+ if key.char == "\r" || key.char == "\n"
860
+ # Enter pressed - accept completion and close dialog
861
+ # Clear context so dialog doesn't reappear
862
+ context.clear
863
+ return
864
+ end
865
+
866
+ # Update pointer (cycle through matches)
867
+ # Tab is now bound to :fuzzy_complete, Shift-Tab to :completion_journey_up
868
+ pointer = if key.method_symbol == :completion_journey_up
869
+ # Shift-Tab - cycle backward
870
+ (pointer - 1) % matches.size
871
+ else
872
+ # Tab (:fuzzy_complete) - cycle forward
873
+ (pointer + 1) % matches.size
874
+ end
875
+
876
+ # Update line buffer with selected completion
877
+ selected = matches[pointer]
878
+
879
+ # Get current line editor state
880
+ le = @line_editor
881
+
882
+ new_line = stored_pre + selected + stored_post
883
+ new_cursor = stored_pre.length + selected.bytesize
884
+
885
+ # Update buffer using public APIs
886
+ le.set_current_line(new_line)
887
+ le.byte_pointer = new_cursor
888
+
889
+ # Update state - mark as navigating so we don't recalculate
890
+ context[4] = pointer
891
+ context[5] = true # Now in navigation mode
892
+ end
893
+
894
+ # Set visual highlight
895
+ dialog.pointer = pointer
896
+
897
+ # Trap Shift-Tab and Enter (Tab is already bound to our dialog)
898
+ dialog.trap_key = [[27, 91, 90], [13]]
899
+
900
+ # Position dropdown
901
+ x = [cursor_pos.x, 0].max
902
+ y = 0
903
+
904
+ # Return dialog
905
+ Reline::DialogRenderInfo.new(
906
+ pos: Reline::CursorPos.new(x, y),
907
+ contents: matches,
908
+ scrollbar: true,
909
+ height: [15, matches.size].min,
910
+ face: :completion_dialog,
911
+ )
912
+ end
913
+
914
+ # Register the custom fuzzy dialog
915
+ Reline.add_dialog_proc(:fuzzy_complete, fuzzy_proc, [])
916
+ end
917
+ end
918
+ end