rubino-agent 0.3.0

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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubino
6
+ module UI
7
+ # The {BottomComposer}'s /command + @file completion menu: an inline
8
+ # navigable list rendered in the multi-row live region above the prompt.
9
+ # Candidates come from the shared CompletionSource. The menu auto-opens as
10
+ # you type a `/` or `@` token (Reline parity); Tab also opens/accepts, ↑/↓
11
+ # navigate, Enter accepts, ESC dismisses immediately (and STICKS for the
12
+ # token) leaving the typed buffer untouched.
13
+ #
14
+ # Pure state machine + row formatting: it reads the buffer/cursor the
15
+ # composer passes in and never prints or takes the render mutex — opening,
16
+ # navigation and the accept SPLICE are decided here, but the composer
17
+ # applies the splice to its buffer and owns every redraw.
18
+ class CompletionMenu
19
+ # Most candidate rows shown at once (the list scrolls within this window
20
+ # for longer candidate sets so the prompt is never pushed off-screen).
21
+ MAX_ROWS = 8
22
+
23
+ # @param completion_source [CompletionSource, nil] shared completion
24
+ # discovery (slash commands + @file picker). nil ⇒ the menu is inert
25
+ # (steering / standalone), so the composer degrades to a plain editor.
26
+ def initialize(completion_source)
27
+ @completion = completion_source
28
+ # Open state: nil when closed, else a Hash with the candidate :items,
29
+ # the :selected index, the :top of the visible window, the :token span
30
+ # being completed (so accept can splice the replacement at the cursor)
31
+ # and the :navigated accept-intent flag.
32
+ @state = nil
33
+ # Sticky ESC-dismiss: once the user presses ESC on an open menu, keep
34
+ # it closed for the CURRENT token instead of re-opening on the next
35
+ # keystroke. Cleared when the token is cleared / on submit / on accept /
36
+ # on an explicit Tab, so a fresh token (or a deliberate Tab) reopens.
37
+ @suppressed = false
38
+ end
39
+
40
+ def open?
41
+ !@state.nil?
42
+ end
43
+
44
+ # The open menu's candidate items (test/inspection helper), nil when closed.
45
+ def items
46
+ @state && @state[:items]
47
+ end
48
+
49
+ # Explicit open (Tab): always clears a sticky ESC-dismiss first — a
50
+ # deliberate Tab reopens a dismissed menu — then opens for the completion
51
+ # context under the cursor, if any. Returns the opened state (truthy; the
52
+ # composer redraws on it), or nil when nothing completes here.
53
+ def open(buffer, cursor)
54
+ @suppressed = false
55
+ ctx = completion_context(buffer, cursor)
56
+ return unless ctx
57
+
58
+ items, start, len = ctx
59
+ @state = { items: items, selected: 0, top: 0, start: start, token_len: len,
60
+ navigated: false }
61
+ end
62
+
63
+ # Open / update / close the menu on every edit and cursor move, matching
64
+ # the old Reline autocompletion: typing a leading `/` or `@` token
65
+ # AUTO-opens the dropdown (no Tab needed), refining as the token grows and
66
+ # closing when it no longer completes. Called from every buffer-edit and
67
+ # cursor-move path so the list always tracks the token under the cursor.
68
+ #
69
+ # * no token under the cursor → close the menu AND clear the sticky
70
+ # ESC-dismiss flag (a fresh token may auto-open again);
71
+ # * token present but ESC-dismissed for it → stay closed;
72
+ # * token with candidates → OPEN a new menu, or UPDATE an open one
73
+ # (preserving the clamped selection); no candidates → close.
74
+ #
75
+ # The selected index is preserved (clamped) across an update so refining
76
+ # the token doesn't jump the highlight back to the top mid-navigation.
77
+ def auto_update(buffer, cursor)
78
+ ctx = completion_context(buffer, cursor)
79
+ if ctx.nil?
80
+ @state = nil
81
+ @suppressed = false # token cleared: a fresh token can auto-open
82
+ return
83
+ end
84
+ return if @suppressed # ESC stuck this token/argument closed
85
+
86
+ items, start, len = ctx
87
+ sel = (@state ? @state[:selected] : 0).clamp(0, items.size - 1)
88
+ @state = { items: items, selected: sel, top: window_top(sel, items.size),
89
+ start: start, token_len: len,
90
+ navigated: @state ? @state[:navigated] : false }
91
+ end
92
+
93
+ # ↑/↓ within the open menu (routed from the composer's history keys).
94
+ # Arrowing marks the menu as NAVIGATED — an explicit accept intent, so
95
+ # Enter on an empty argument token accepts the highlight instead of
96
+ # submitting the buffer (see #exact_command?).
97
+ def up
98
+ @state[:selected] = [@state[:selected] - 1, 0].max
99
+ navigated_to_selection
100
+ end
101
+
102
+ def down
103
+ @state[:selected] = [@state[:selected] + 1, @state[:items].size - 1].min
104
+ navigated_to_selection
105
+ end
106
+
107
+ # Accept the highlighted candidate: returns the splice the composer
108
+ # applies — [start, token_len, replacement] where the replacement carries
109
+ # a trailing space (so the next token starts clean, like Reline's append
110
+ # char) — and closes the menu (clearing the sticky dismiss: accepting
111
+ # ends this token; a new one can auto-open).
112
+ def accept_splice
113
+ choice = @state[:items][@state[:selected]].to_s
114
+ splice = [@state[:start], @state[:token_len], "#{choice} "]
115
+ close!
116
+ splice
117
+ end
118
+
119
+ # True when the buffer is ALREADY an exact, complete command, so Enter
120
+ # should SUBMIT it rather than accept-and-space (D5/#147). Compares the
121
+ # TOKEN the menu would splice (not the whole buffer, which never matches
122
+ # a bare argument candidate — that's what swallowed Enter on a fully
123
+ # typed `/agents sa_xxx`): submit when the typed token equals a
124
+ # candidate exactly AND that match is the menu's current selection (or
125
+ # the only candidate) — so a partial/ambiguous token (e.g. "/re" with
126
+ # /reasoning + /reset) still accepts the highlight on Enter as before.
127
+ # An EMPTY argument token (`/agents sa_xxx ` with the verb dropdown
128
+ # open) also submits — the buffer is already a complete command and
129
+ # accepting would splice a verb the user never typed — UNLESS the user
130
+ # explicitly arrow-navigated onto a candidate, which is an accept
131
+ # intent. Tab-accept is untouched.
132
+ def exact_command?(buffer)
133
+ return false unless @state
134
+
135
+ typed = Array(buffer.chars[@state[:start], @state[:token_len]]).join
136
+ return !@state[:navigated] if typed.empty?
137
+
138
+ items = @state[:items]
139
+ return false unless items.include?(typed)
140
+
141
+ selected = items[@state[:selected]].to_s
142
+ items.size == 1 || selected == typed
143
+ end
144
+
145
+ # Close the menu and clear the sticky ESC-dismiss flag (submit / accept):
146
+ # the next token starts fresh and is free to auto-open again.
147
+ def close!
148
+ @state = nil
149
+ @suppressed = false
150
+ end
151
+
152
+ # Lone-ESC dismiss: close AND STICK for the current token so it doesn't
153
+ # pop back on the next keystroke. Cleared when the token changes to nil,
154
+ # on submit/accept, or on an explicit Tab (see #auto_update / #close!).
155
+ def dismiss!
156
+ @state = nil
157
+ @suppressed = true
158
+ end
159
+
160
+ # Teardown hide (composer stop/suspend): close the rows without touching
161
+ # the sticky dismiss, so a resume mid-token behaves exactly as before.
162
+ def hide!
163
+ @state = nil
164
+ end
165
+
166
+ # The rendered menu rows (the slice in view, the selected one marked with
167
+ # a cyan ❯ and inverse highlight), or [] when no menu is open. House
168
+ # grammar: a dim aside bar leads each row. Candidates with a registered
169
+ # description (BuiltIns/custom command one-liners, the /agents subcommand
170
+ # hints) show it dim in an aligned column next to the name (#39).
171
+ def rows(cols)
172
+ return [] unless @state
173
+
174
+ items = @state[:items]
175
+ top = @state[:top]
176
+ sel = @state[:selected]
177
+ slice = items[top, MAX_ROWS] || []
178
+ pad = slice.map { |item| LiveRegion.display_width(item.to_s) }.max.to_i
179
+ rows = slice.each_with_index.map do |item, i|
180
+ candidate_row(item, pad, cols, selected: top + i == sel)
181
+ end
182
+ rows << pastel.dim("┄ #{sel + 1}/#{items.size} ┄") if items.size > MAX_ROWS
183
+ rows
184
+ end
185
+
186
+ private
187
+
188
+ def navigated_to_selection
189
+ @state[:top] = window_top(@state[:selected], @state[:items].size)
190
+ @state[:navigated] = true
191
+ end
192
+
193
+ def candidate_row(item, pad, cols, selected:)
194
+ row = if selected
195
+ "#{pastel.cyan("❯")} #{pastel.inverse(" #{item} ")}"
196
+ else
197
+ "#{pastel.dim("┊")} #{item}"
198
+ end
199
+ desc = description(item, pad, cols)
200
+ if desc
201
+ # Align the description column across rows: the inverse highlight
202
+ # already widens the selected name by 2 (its padding spaces).
203
+ row += (" " * (pad - LiveRegion.display_width(item.to_s) + (selected ? 0 : 2)))
204
+ row += pastel.dim(desc)
205
+ end
206
+ row
207
+ end
208
+
209
+ # The dim description for a menu candidate, fitted to the row budget so a
210
+ # long one-liner is right-truncated here instead of the shared row clamp
211
+ # left-truncating the candidate NAME away. nil when the source has none
212
+ # (files, skill names) or the row is too narrow to show one usefully.
213
+ def description(item, pad, cols)
214
+ return nil unless @completion.respond_to?(:description_for)
215
+
216
+ desc = @completion.description_for(item).to_s
217
+ return nil if desc.empty?
218
+
219
+ budget = cols - pad - 6 # glyph + gaps + the one-column scroll guard
220
+ return nil if budget < 8
221
+
222
+ desc.length > budget ? "#{desc[0, budget - 1]}…" : desc
223
+ end
224
+
225
+ # Resolve what to complete at the cursor: returns [items, start, len]
226
+ # where +items+ are the candidate strings, +start+ the codepoint index
227
+ # where the splice begins, and +len+ the length of the text the accepted
228
+ # choice replaces — or nil when nothing completes here.
229
+ #
230
+ # Two shapes, in priority order:
231
+ # 1. COMMAND ARGUMENT — the buffer is `/<cmd> <partial>` and <cmd> has a
232
+ # registered argument source (e.g. `/skills ruby` → skill names). The
233
+ # partial (possibly empty) is the splice span; this is what lets the
234
+ # SAME dropdown pick a skill name as it picks a /command or @file.
235
+ # 2. LEADING TOKEN — a `/command` or `@file` token under the cursor
236
+ # (the original behavior), spliced over the whole token.
237
+ def completion_context(buffer, cursor)
238
+ return nil unless @completion
239
+
240
+ if (arg = command_arg_context(buffer, cursor))
241
+ command, partial, start, args = arg
242
+ items = arg_candidates(command, partial, args)
243
+ return nil if items.empty?
244
+
245
+ return [items, start, partial.chars.length]
246
+ end
247
+
248
+ tok = current_token(buffer, cursor)
249
+ return nil unless tok
250
+
251
+ token, start = tok
252
+ items = candidates(token)
253
+ return nil if items.empty?
254
+
255
+ [items, start, token.chars.length]
256
+ end
257
+
258
+ # The completion TOKEN under the cursor: the leading run of non-space
259
+ # chars from the start of the line up to the cursor, when it begins with
260
+ # / or @. Returns [token, start_index] or nil when the cursor isn't on a
261
+ # token.
262
+ def current_token(buffer, cursor)
263
+ prefix = buffer.chars.first(cursor).join
264
+ # Only the FIRST token on the line completes (a leading /command, or an
265
+ # @mention anywhere the run back to a space starts with @).
266
+ m = prefix.match(%r{(?:\A|\s)([/@]\S*)\z})
267
+ return nil unless m
268
+
269
+ [m[1], m.begin(1)]
270
+ end
271
+
272
+ # When the buffer is an ARGUMENT position of a slash command — i.e.
273
+ # `/<cmd> [args…] <partial>` with the cursor in the trailing argument —
274
+ # returns [command, partial, partial_start, args] so
275
+ # {#completion_context} can complete it; nil otherwise. +args+ are the
276
+ # COMPLETE arguments before the partial, so a positional source can own a
277
+ # subcommand grammar (`/agents <id> steer|probe|--stop`, #39); whether a
278
+ # position completes at all is the CompletionSource's call (a
279
+ # single-argument command like /skills stops after its first).
280
+ def command_arg_context(buffer, cursor)
281
+ prefix = buffer.chars.first(cursor).join
282
+ m = prefix.match(%r{\A/(\S+)((?:[ \t]+\S+)*)[ \t]+(\S*)\z})
283
+ return nil unless m
284
+
285
+ [m[1], m[3], m.begin(3), m[2].split]
286
+ end
287
+
288
+ def candidates(token)
289
+ @completion.candidates_for(token)
290
+ rescue StandardError
291
+ []
292
+ end
293
+
294
+ # Argument candidates for a slash command (e.g. skill names for `/skills`,
295
+ # ids + steer/probe/--stop for `/agents`), via the CompletionSource.
296
+ # Guarded so a registry hiccup degrades the menu to closed rather than
297
+ # crashing the prompt — same contract as #candidates.
298
+ def arg_candidates(command, partial, args)
299
+ return [] unless @completion.respond_to?(:arg_candidates_for)
300
+
301
+ @completion.arg_candidates_for(command, partial, args)
302
+ rescue StandardError
303
+ []
304
+ end
305
+
306
+ # The visible window's top index so the selected row stays in view.
307
+ def window_top(selected, size)
308
+ return 0 if size <= MAX_ROWS
309
+
310
+ top = @state ? @state[:top] : 0
311
+ top = selected if selected < top
312
+ top = selected - MAX_ROWS + 1 if selected >= top + MAX_ROWS
313
+ top.clamp(0, size - MAX_ROWS)
314
+ end
315
+
316
+ def pastel
317
+ @pastel ||= Pastel.new
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "open3"
5
+
6
+ module Rubino
7
+ module UI
8
+ # Shared completion DISCOVERY + token HIGHLIGHT for the interactive prompt.
9
+ # The bottom composer's /command + @file completion menu and token highlight
10
+ # consult this single implementation (git→rg→glob walk, @file candidate
11
+ # shaping, caps/TTL cache, cyan leading-token highlight) instead of each
12
+ # path duplicating it.
13
+ #
14
+ # * +candidates_for(token)+ — slash commands or @file paths for a token.
15
+ # * +highlight_line(line)+ — cyan the leading /command / @mention token.
16
+ #
17
+ # Discovery is fastest-first (git tracked+untracked honoring .gitignore →
18
+ # ripgrep --files → a capped Dir.glob walk) and memoized for a few seconds so
19
+ # a burst of @ keystrokes never reshells. Every tier is guarded so a failure
20
+ # degrades to the next tier (and finally to []), never crashing the prompt.
21
+ class CompletionSource
22
+ # Tokens that trigger highlighting at the start of the line. A leading
23
+ # `!` (the bang shell escape) glows like `/` so the user can SEE the
24
+ # line will run as a shell command, not a message — highlight only, it
25
+ # never opens the completion menu.
26
+ TRIGGER_TOKEN = %r{\A([/@]\S+|!\S*)}
27
+
28
+ # Cap on candidates — keeps the menu skimmable and bounds work on huge
29
+ # repos. Cline et al. ship similar caps.
30
+ MAX_CANDIDATES = 30
31
+
32
+ # How long a computed file list stays warm before the next `@` reshells.
33
+ FILE_CACHE_TTL = 5.0
34
+
35
+ # Hardcoded ignore set for the last-resort Dir.glob walk (git/rg already
36
+ # honor .gitignore; this is only the fallback's safety net).
37
+ GLOB_IGNORE_DIRS = %w[.git node_modules vendor tmp log .bundle].freeze
38
+
39
+ # Hard ceiling on the Dir.glob fallback so a giant tree can't hang the
40
+ # prompt while we walk it.
41
+ GLOB_MAX_FILES = 5000
42
+
43
+ # The `✗ none` clear entry shown at the TOP of an argument list whose
44
+ # command supports clearing its active selection (e.g. `/skills`). Picking
45
+ # it submits the bare sentinel so the command handler clears the slot.
46
+ NONE_ENTRY = "✗ none"
47
+
48
+ # @param commands [Array<String>] the slash-command names (incl. leading /)
49
+ # @param files [#call, nil] lazy proc returning the workspace root to scan
50
+ # @param arg_sources [Hash{String=>#call}] maps a BARE command name (no
51
+ # leading slash, e.g. "skills") to a proc returning that command's
52
+ # argument candidates. Two shapes:
53
+ # * a NO-ARG proc — a single-argument command (e.g. "skills" → the
54
+ # skill names); only the FIRST argument completes, and the list is
55
+ # prefixed with the `✗ none` clear entry (NONE_ENTRY) so the picker
56
+ # can clear the active selection from the top.
57
+ # * a ONE-ARG proc — receives the PRIOR-argument array and decides
58
+ # what completes at this position (e.g. "agents": [] → live ids,
59
+ # [id] → steer/probe/--stop), so a subcommand grammar is
60
+ # discoverable from the same dropdown (#39). No `✗ none` entry is
61
+ # injected — but the source may INCLUDE the NONE_ENTRY string in
62
+ # its own list (e.g. "skills", whose first position mixes the
63
+ # activate-by-name list with the enable/disable verbs, #188), and
64
+ # it keeps the same special matching the no-arg shape gives it.
65
+ # Closed enums (`/mode`, `/reasoning`, `/think`, #185) use this
66
+ # shape too — `->(args) { args.empty? ? VALUES : [] }` — exactly
67
+ # because it carries no `✗ none` entry (there is no "clear" for a
68
+ # mode; the no-arg shape's prefix would offer a bogus value).
69
+ # * a TWO-ARG proc — receives (prior args, the PARTIAL typed so far)
70
+ # and OWNS the matching (no additional prefix filter): a
71
+ # filesystem-path source (`/add-dir`, #185) expands `~`, which a
72
+ # literal prefix filter would drop. No `✗ none` entry.
73
+ # @param descriptions [Hash{String=>String}] one-line description per
74
+ # candidate string (e.g. BuiltIns::DESCRIPTIONS), rendered dim next to
75
+ # the name in the dropdown (#39). Candidates without an entry show
76
+ # bare, as before.
77
+ def initialize(commands: [], files: nil, arg_sources: {}, descriptions: {})
78
+ @commands = Array(commands).uniq
79
+ @files_root_proc = files
80
+ @arg_sources = arg_sources || {}
81
+ @descriptions = descriptions || {}
82
+ @pastel = Pastel.new
83
+ end
84
+
85
+ # Candidates for a completion token. A `/`-prefixed token completes from
86
+ # the command list; an `@`-prefixed token completes from workspace files;
87
+ # anything else has no candidates. Case-insensitive prefix matching.
88
+ def candidates_for(token)
89
+ case token
90
+ when %r{\A/}
91
+ down = token.downcase
92
+ @commands.select { |c| c.downcase.start_with?(down) }
93
+ when /\A@/
94
+ file_candidates(token)
95
+ else
96
+ []
97
+ end
98
+ end
99
+
100
+ # Candidates for the ARGUMENT of a command, e.g. the skill names when the
101
+ # buffer is `/skills <partial>`. +command+ is the bare command name (no
102
+ # leading slash); +partial+ is the text typed so far for the argument
103
+ # (may be empty); +args+ the COMPLETE arguments typed before it. Returns
104
+ # [] when the command has no registered argument source.
105
+ #
106
+ # Candidates are filtered by case-insensitive prefix and capped at
107
+ # MAX_CANDIDATES — the SAME cap the `/command` and `@file` lists honor.
108
+ # A no-arg source (single-argument command) completes only the first
109
+ # argument and leads with the `✗ none` clear entry; a one-arg source is
110
+ # called with +args+ and owns the per-position grammar (#39) — see
111
+ # #initialize.
112
+ def arg_candidates_for(command, partial, args = [])
113
+ source = @arg_sources[command.to_s]
114
+ return [] unless source
115
+
116
+ down = partial.to_s.downcase
117
+ list =
118
+ if source.arity.zero?
119
+ return [] unless args.empty? # single-argument command: first arg only
120
+
121
+ # The `✗ none` clear entry matches an empty partial or a
122
+ # "n"/"no…"/"none" prefix, so typing toward "none" keeps it in view.
123
+ none = down.empty? || NONE.start_with?(down) ? [NONE_ENTRY] : []
124
+ none + Array(source.call).select { |n| n.to_s.downcase.start_with?(down) }
125
+ elsif source.arity == 2
126
+ # PARTIAL-AWARE source: it derives candidates FROM the typed text
127
+ # (e.g. a filesystem glob) and owns the matching — see #initialize.
128
+ Array(source.call(args, partial.to_s))
129
+ else
130
+ positional_candidates(source.call(args), down)
131
+ end
132
+ list.first(MAX_CANDIDATES)
133
+ end
134
+
135
+ # Prefix-filtered candidates from a positional (one-arg) source. A
136
+ # literal NONE_ENTRY in the source's list (the /skills first position,
137
+ # #188) keeps the clear entry's special matching — shown on an empty
138
+ # partial or while typing toward "none" — instead of being dropped by
139
+ # the literal `✗ ` prefix filter.
140
+ def positional_candidates(list, down)
141
+ list = Array(list)
142
+ has_none = list.delete(NONE_ENTRY)
143
+ matched = list.select { |n| n.to_s.downcase.start_with?(down) }
144
+ return matched unless has_none && (down.empty? || NONE.start_with?(down))
145
+
146
+ [NONE_ENTRY] + matched
147
+ end
148
+
149
+ # Directory candidates for a PATH-shaped argument (`/add-dir `, #185) —
150
+ # the directory-flavored sibling of the `@file` picker. Globs the
151
+ # filesystem from the typed partial (relative to cwd, absolute, or
152
+ # `~`-prefixed — an added root usually lives OUTSIDE the workspace, so
153
+ # the workspace file list is the wrong source here), keeps only
154
+ # directories, and folds `~` back so the spliced candidate preserves the
155
+ # user's spelling. Best-effort: any failure (e.g. `~nouser`) returns [].
156
+ def self.directory_candidates(partial)
157
+ text = partial.to_s
158
+ pattern = text.start_with?("~") ? File.expand_path(text) : text
159
+ Dir.glob("#{pattern}*")
160
+ .select { |p| File.directory?(p) }
161
+ .sort
162
+ .map { |p| text.start_with?("~") ? p.sub(File.expand_path("~"), "~") : p }
163
+ .first(MAX_CANDIDATES)
164
+ rescue StandardError
165
+ []
166
+ end
167
+
168
+ # The sentinel a `✗ none` selection resolves to once spliced + submitted —
169
+ # the command handler treats this argument as "clear the active selection".
170
+ NONE = "none"
171
+
172
+ # The one-line description for a dropdown candidate (#39): the same
173
+ # strings /help shows for a `/command`, a usage hint for a subcommand.
174
+ # nil when the candidate has none (files, skill names) — the menu row
175
+ # renders bare, exactly as before.
176
+ def description_for(candidate)
177
+ @descriptions[candidate.to_s]
178
+ end
179
+
180
+ # Subtly colorize a leading /command or @mention token (cyan). Plain text
181
+ # and non-strings are returned unchanged. Matches LineInput#highlight_line.
182
+ # A "[Pasted text #N +M lines]" paste placeholder (UI::PasteStore) glows
183
+ # the same way wherever it sits in the line, so the user can SEE it is a
184
+ # token that expands at send, not literal text.
185
+ def highlight_line(line)
186
+ return line unless line.is_a?(String)
187
+
188
+ line.sub(TRIGGER_TOKEN) { @pastel.cyan(Regexp.last_match(1)) }
189
+ .gsub(PasteStore::TOKEN_RE) { |token| @pastel.cyan(token) }
190
+ end
191
+
192
+ private
193
+
194
+ # Turn an `@<partial>` token into `@<relpath>` candidates, prefix-matching
195
+ # the relative path case-insensitively (MVP is prefix-only, as Cline ships).
196
+ def file_candidates(token)
197
+ partial = token.sub(/\A@/, "")
198
+ down = partial.downcase
199
+
200
+ workspace_files
201
+ .lazy
202
+ .select { |rel| rel.downcase.start_with?(down) }
203
+ .map { |rel| "@#{rel}" }
204
+ .first(MAX_CANDIDATES)
205
+ end
206
+
207
+ # Workspace-relative file list, discovered once per `@` burst and memoized
208
+ # for FILE_CACHE_TTL so we never reshell on every keystroke.
209
+ def workspace_files
210
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
+ return @files_cache if @files_cache && @files_cache_at && (now - @files_cache_at) < FILE_CACHE_TTL
212
+
213
+ @files_cache = discover_files(workspace_root)
214
+ @files_cache_at = now
215
+ @files_cache
216
+ end
217
+
218
+ # Same source of truth as Tools::Base#workspace_root (the primary root).
219
+ def workspace_root
220
+ root = @files_root_proc&.call if @files_root_proc
221
+ root || Rubino::Workspace.primary_root
222
+ rescue StandardError
223
+ Dir.pwd
224
+ end
225
+
226
+ # Ignore-aware file discovery, fastest-first. Any failure at a tier falls
227
+ # through; if everything fails we return [] and the prompt keeps working.
228
+ def discover_files(root)
229
+ return [] unless root && File.directory?(root)
230
+
231
+ git_files(root) || rg_files(root) || glob_files(root) || []
232
+ rescue StandardError
233
+ []
234
+ end
235
+
236
+ # (1) git: tracked + untracked, honoring .gitignore. nil (not []) on
237
+ # failure so the caller falls through to the next tier. err: File::NULL so
238
+ # git's "fatal: not a git repository" never bleeds onto the prompt (D5).
239
+ def git_files(root)
240
+ out, status = Open3.capture2(
241
+ "git", "ls-files", "--cached", "--others", "--exclude-standard",
242
+ chdir: root, err: File::NULL
243
+ )
244
+ return nil unless status.success?
245
+
246
+ out.split("\n").reject(&:empty?)
247
+ rescue StandardError
248
+ nil
249
+ end
250
+
251
+ # (2) ripgrep: --files lists every file rg would search (.gitignore aware).
252
+ def rg_files(root)
253
+ return nil unless ripgrep_available?
254
+
255
+ out, status = Open3.capture2("rg", "--files", chdir: root, err: File::NULL)
256
+ return nil unless status.success?
257
+
258
+ out.split("\n").reject(&:empty?)
259
+ rescue StandardError
260
+ nil
261
+ end
262
+
263
+ def ripgrep_available?
264
+ system("which rg > /dev/null 2>&1")
265
+ end
266
+
267
+ # (3) last resort: a capped, ignore-aware Dir.glob walk.
268
+ def glob_files(root)
269
+ files = []
270
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root) do |rel|
271
+ next if [".", ".."].include?(rel)
272
+ next if GLOB_IGNORE_DIRS.any? { |d| rel == d || rel.start_with?("#{d}/") }
273
+ next unless File.file?(File.join(root, rel))
274
+
275
+ files << rel
276
+ break if files.size >= GLOB_MAX_FILES
277
+ end
278
+ files
279
+ rescue StandardError
280
+ nil
281
+ end
282
+ end
283
+ end
284
+ end