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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Null UI adapter that discards all output.
6
+ # Used in testing and background job execution
7
+ # where no terminal output is needed.
8
+ class Null < Base
9
+ attr_reader :messages
10
+
11
+ def initialize
12
+ @messages = []
13
+ end
14
+
15
+ def info(message)
16
+ @messages << { level: :info, message: message }
17
+ end
18
+
19
+ def success(message)
20
+ @messages << { level: :success, message: message }
21
+ end
22
+
23
+ def warning(message)
24
+ @messages << { level: :warning, message: message }
25
+ end
26
+
27
+ def error(message)
28
+ @messages << { level: :error, message: message }
29
+ end
30
+
31
+ def status(message)
32
+ @messages << { level: :status, message: message }
33
+ end
34
+
35
+ def box_open(*pieces, at: nil, color: nil)
36
+ @messages << { level: :box_open, pieces: pieces, at: at, color: color }
37
+ end
38
+
39
+ def box_close(*pieces, color: nil)
40
+ @messages << { level: :box_close, pieces: pieces, color: color }
41
+ end
42
+
43
+ def body(text)
44
+ @messages << { level: :body, message: text }
45
+ end
46
+
47
+ def assistant_text(text)
48
+ @messages << { level: :assistant_text, message: text }
49
+ end
50
+
51
+ def note(text)
52
+ @messages << { level: :note, message: text }
53
+ end
54
+
55
+ def probe_aside(answer)
56
+ @messages << { level: :probe_aside, message: answer.to_s }
57
+ end
58
+
59
+ def branch_confirmation(new_id:, parent_id:, title:, included_probe:)
60
+ @messages << {
61
+ level: :branch_confirmation,
62
+ message: { new_id: new_id, parent_id: parent_id, title: title,
63
+ included_probe: included_probe }
64
+ }
65
+ end
66
+
67
+ def stream(chunk)
68
+ # Every adapter yields the common chunk contract:
69
+ # { type: :content | :thinking, text: String, message_id: Integer }
70
+ text = chunk[:text].to_s
71
+ type = chunk[:type] || :content
72
+ @messages << { level: :stream, message: text, stream_type: type }
73
+ end
74
+
75
+ def stream_end
76
+ @messages << { level: :stream_end, message: "" }
77
+ end
78
+
79
+ def replay_user_input(text, at: nil)
80
+ @messages << { level: :replay_user_input, message: text, at: at }
81
+ end
82
+
83
+ def thinking_started
84
+ @messages << { level: :thinking_started, message: "" }
85
+ end
86
+
87
+ def thinking_finished
88
+ @messages << { level: :thinking_finished, message: "" }
89
+ end
90
+
91
+ def table(headers:, rows:)
92
+ @messages << { level: :table, message: { headers: headers, rows: rows } }
93
+ end
94
+
95
+ def ask(_prompt)
96
+ nil
97
+ end
98
+
99
+ # No interactive selection off a real terminal; callers fall back to a
100
+ # non-interactive path (e.g. the static /sessions table + shortcut).
101
+ def select(_prompt, _choices)
102
+ nil
103
+ end
104
+
105
+ # `scope:` is part of the shared UI contract (ToolExecutor always
106
+ # passes it); the Null adapter auto-approves and ignores it.
107
+ def confirm(_question, scope: nil, **_context)
108
+ true
109
+ end
110
+
111
+ # Destructive confirm (#218): no human to ask, so fail closed (decline)
112
+ # — never destroy on the non-interactive Null adapter.
113
+ def confirm_destructive(_question)
114
+ false
115
+ end
116
+
117
+ def tool_started(name, arguments: nil, at: nil)
118
+ @messages << { level: :tool_started, message: name, arguments: arguments, at: at }
119
+ end
120
+
121
+ def tool_finished(name, result: nil)
122
+ @messages << { level: :tool_finished, message: name }
123
+ end
124
+
125
+ def tool_body(text, kind: :plain)
126
+ @messages << { level: :tool_body, message: text, kind: kind }
127
+ end
128
+
129
+ def tool_chunk(name, chunk)
130
+ @messages << { level: :tool_chunk, name: name, chunk: chunk }
131
+ end
132
+
133
+ def compression_started(at: nil)
134
+ @messages << { level: :compression_started, message: "", at: at }
135
+ end
136
+
137
+ def compression_finished(metadata, at: nil)
138
+ @messages << { level: :compression_finished, message: metadata, at: at }
139
+ end
140
+
141
+ def job_enqueued(type)
142
+ @messages << { level: :job_enqueued, message: type }
143
+ end
144
+
145
+ def job_started(type)
146
+ @messages << { level: :job_started, message: type }
147
+ end
148
+
149
+ def job_finished(type)
150
+ @messages << { level: :job_finished, message: type }
151
+ end
152
+
153
+ def separator
154
+ @messages << { level: :separator, message: "" }
155
+ end
156
+
157
+ def blank_line
158
+ @messages << { level: :blank_line, message: "" }
159
+ end
160
+
161
+ def mode_changed(name, previous: nil)
162
+ @messages << { level: :mode_changed, message: name, previous: previous }
163
+ end
164
+
165
+ def reasoning_status(mode)
166
+ @messages << { level: :reasoning_status, message: mode }
167
+ end
168
+
169
+ def reasoning_changed(mode, previous: nil)
170
+ @messages << { level: :reasoning_changed, message: mode, previous: previous }
171
+ end
172
+
173
+ def think_status(effort)
174
+ @messages << { level: :think_status, message: effort }
175
+ end
176
+
177
+ def think_changed(effort, previous: nil)
178
+ @messages << { level: :think_changed, message: effort, previous: previous }
179
+ end
180
+
181
+ def queued(text)
182
+ @messages << { level: :queued, message: text }
183
+ end
184
+
185
+ def input_injected(text)
186
+ @messages << { level: :input_injected, message: text }
187
+ end
188
+
189
+ # Resets captured messages (useful between test cases)
190
+ def reset!
191
+ @messages = []
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubino
6
+ module UI
7
+ # The per-session PASTE store behind the composer's file-backed paste
8
+ # pipeline (Hermes-style, two tiers).
9
+ #
10
+ # A large bracketed paste does not flood the composer: the body is
11
+ # registered here and a single compact PLACEHOLDER token —
12
+ # "[Pasted text #1 +123 lines]" — is inserted into the editable buffer
13
+ # instead. The token rides the draft like normal text (editable around,
14
+ # history-recalled, queueable) and is EXPANDED to the full body only at
15
+ # the message-build seam, where the line leaves the composer for the
16
+ # agent loop (ChatCommand#run_turn): the model sees everything, while the
17
+ # transcript echo keeps the placeholder so scrollback stays clean.
18
+ #
19
+ # Two tiers, both behind one placeholder shape:
20
+ #
21
+ # * Tier 1 — PLACEHOLDER COLLAPSE: a paste longer than
22
+ # `paste.collapse_lines` lines (default 5) is held in memory and the
23
+ # token expands to the verbatim body at submit.
24
+ # * Tier 2 — FILE OVERFLOW: a paste bigger than
25
+ # `paste.file_threshold_tokens` (default 8000, estimated at the same
26
+ # chars/4 rule Context::TokenBudget uses) is written to a session-
27
+ # scoped file — <RUBINO_HOME>/sessions/<id>/paste_N.txt — and the
28
+ # token expands to a one-line pointer telling the model to read the
29
+ # file with the read tool. The home sessions dir is where session
30
+ # artifacts already live, it never pollutes the workspace tree, and
31
+ # the read tool is deliberately un-sandboxed (only WRITES are gated
32
+ # by Workspace roots), so the model can read it from any cwd.
33
+ #
34
+ # Lifecycle: a tier-1 body is consumed when its token is expanded into an
35
+ # outgoing message (re-submitting the line from history later leaves the
36
+ # literal placeholder, matching Hermes); tier-2 files persist for the
37
+ # session so the model can re-read them in later turns. Pastes at or
38
+ # under the collapse threshold never reach the store — they inline into
39
+ # the buffer exactly as before.
40
+ class PasteStore
41
+ # The placeholder shape, shared with the CompletionSource highlight and
42
+ # the composer's whole-token backspace.
43
+ TOKEN_RE = /\[Pasted text #\d+ \+\d+ lines\]/
44
+
45
+ # Built-in fallbacks when config is missing/garbage.
46
+ DEFAULT_COLLAPSE_LINES = 5
47
+ DEFAULT_THRESHOLD_TOKENS = 8000
48
+
49
+ # @param config [Config::Configuration, nil] resolved lazily from
50
+ # Rubino.configuration when nil, so a long-lived store follows config
51
+ # reloads.
52
+ # @param session_source [#call, String, nil] the session id the tier-2
53
+ # files are scoped under. A callable is resolved at WRITE time, so the
54
+ # chat loop can hand a closure over its (re-assignable) runner and
55
+ # /new //sessions //branch swaps are honored without re-wiring.
56
+ def initialize(config: nil, session_source: nil)
57
+ @config = config
58
+ @session_source = session_source
59
+ @entries = {} # placeholder token => expansion text
60
+ @counter = 0
61
+ end
62
+
63
+ # Late wiring for the session scope (see #initialize) — the chat command
64
+ # builds the store before the runner exists.
65
+ attr_writer :session_source
66
+
67
+ # True when +body+ should collapse to a placeholder instead of inlining:
68
+ # strictly more lines than paste.collapse_lines.
69
+ def collapse?(body)
70
+ body.to_s.lines.length > collapse_lines
71
+ end
72
+
73
+ # Registers a pasted +body+ and returns the placeholder token to insert
74
+ # into the buffer. Oversized bodies (tier 2) are written to the session
75
+ # paste file here, at paste time; their token expands to the file
76
+ # pointer instead of the content.
77
+ def register(body)
78
+ body = body.to_s
79
+ n = (@counter += 1)
80
+ token = "[Pasted text ##{n} +#{body.lines.length} lines]"
81
+ @entries[token] = oversize?(body) ? overflow_to_file(n, body) : body
82
+ token
83
+ end
84
+
85
+ # Expands every registered placeholder in +text+ to its stored body
86
+ # (tier 1) or file pointer (tier 2) — the message-build seam. Consumed
87
+ # entries are dropped ("cleared on submit"); unknown placeholder-shaped
88
+ # text is left verbatim, so user-typed literals are never rewritten.
89
+ def expand(text)
90
+ return text unless text.is_a?(String) && @entries.keys.any? { |t| text.include?(t) }
91
+
92
+ text.gsub(TOKEN_RE) { |token| @entries.delete(token) || token }
93
+ end
94
+
95
+ # The registered [token, body] pairs whose placeholder appears in +text+,
96
+ # CONSUMING them like #expand does (re-submitting from history later leaves
97
+ # the literal placeholder). Returned as an array of pairs so the tokens
98
+ # survive JSON round-trips intact (a token is not a valid symbol key).
99
+ # Empty array when +text+ carries no live placeholder.
100
+ def expansions_in(text)
101
+ return [] unless text.is_a?(String)
102
+
103
+ text.scan(TOKEN_RE).uniq.filter_map do |token|
104
+ body = @entries.delete(token)
105
+ [token, body] if body
106
+ end
107
+ end
108
+
109
+ # The [start, length] (codepoint) span of the registered placeholder
110
+ # covering the char just BEFORE +cursor+ in +buffer+, or nil. The
111
+ # composer's backspace uses it to delete a placeholder WHOLE — a
112
+ # half-eaten token would neither read nor expand. Only spans the store
113
+ # actually registered qualify; lookalike text the user typed is edited
114
+ # char-by-char as usual.
115
+ def placeholder_span(buffer, cursor)
116
+ return nil if @entries.empty? || buffer.nil?
117
+
118
+ pos = 0
119
+ while (m = TOKEN_RE.match(buffer, pos))
120
+ start = m.begin(0)
121
+ length = m[0].length
122
+ return [start, length] if @entries.key?(m[0]) && cursor > start && cursor <= start + length
123
+
124
+ pos = start + length
125
+ end
126
+ nil
127
+ end
128
+
129
+ private
130
+
131
+ def collapse_lines
132
+ positive(config&.paste_collapse_lines) || DEFAULT_COLLAPSE_LINES
133
+ end
134
+
135
+ def threshold_tokens
136
+ positive(config&.paste_file_threshold_tokens) || DEFAULT_THRESHOLD_TOKENS
137
+ end
138
+
139
+ # Tier-2 gate: the same chars/4 estimate compaction runs on
140
+ # (Context::TokenBudget::CHARS_PER_TOKEN), so "a context share" here and
141
+ # the status bar / compactor agree on what a token is.
142
+ def oversize?(body)
143
+ (body.length / Context::TokenBudget::CHARS_PER_TOKEN) > threshold_tokens
144
+ end
145
+
146
+ # Write the oversized body to <home>/sessions/<id>/paste_N.txt and
147
+ # return the pointer line its token expands to. Best-effort: if the
148
+ # write fails for any reason the body is kept in memory (tier-1
149
+ # behavior) — a paste must never be lost to a disk hiccup.
150
+ def overflow_to_file(num, body)
151
+ dir = session_dir
152
+ FileUtils.mkdir_p(dir)
153
+ path = File.join(dir, "paste_#{num}.txt")
154
+ File.write(path, body)
155
+ "[Pasted text ##{num} saved to #{path} — too large to inline; read it with the read tool]"
156
+ rescue StandardError
157
+ body
158
+ end
159
+
160
+ def session_dir
161
+ id = @session_source.respond_to?(:call) ? @session_source.call : @session_source
162
+ id = "pastes-#{Process.pid}" if id.nil? || id.to_s.empty?
163
+ File.join(Rubino.home_path, "sessions", id.to_s)
164
+ end
165
+
166
+ def config
167
+ @config || Rubino.configuration
168
+ end
169
+
170
+ def positive(value)
171
+ v = value.to_i
172
+ v.positive? ? v : nil
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubino
6
+ module UI
7
+ # Shared printing behaviour for terminal-based UI adapters.
8
+ #
9
+ # Subclasses must implement #color_for(role) returning a Pastel method name
10
+ # (e.g. :cyan, :green) so that message formatting stays here while each
11
+ # adapter controls its own color scheme.
12
+ class PrinterBase < Base
13
+ def initialize
14
+ @pastel = Pastel.new
15
+ end
16
+
17
+ def info(message) = puts_colored(color_for(:info), message)
18
+ def success(message) = puts_colored(color_for(:success), "✓ #{message}")
19
+ def warning(message) = puts_colored(color_for(:warning), "⚠ #{message}")
20
+ def error(message) = puts_colored(color_for(:error), "✗ #{message}")
21
+ def status(message) = puts_colored(color_for(:status), message)
22
+
23
+ def stream(chunk)
24
+ text = chunk[:text].to_s
25
+ $stdout.print text
26
+ $stdout.flush
27
+ end
28
+
29
+ def stream_end
30
+ $stdout.puts
31
+ end
32
+
33
+ def tool_started(name, arguments: nil, at: nil)
34
+ puts_colored(color_for(:tool), " → Running tool: #{name}")
35
+ end
36
+
37
+ def tool_finished(name, result: nil)
38
+ suffix = result ? " (#{result.truncated_preview})" : ""
39
+ puts_colored(color_for(:tool), " ← #{name} done#{suffix}")
40
+ end
41
+
42
+ def compression_started(at: nil)
43
+ puts_colored(color_for(:muted), " ⟳ Compacting context...")
44
+ end
45
+
46
+ def compression_finished(metadata, at: nil)
47
+ saved = metadata[:saved_tokens] || 0
48
+ puts_colored(color_for(:muted), " ⟳ Context compacted (saved #{saved} tokens)")
49
+ end
50
+
51
+ def job_enqueued(_type) = nil
52
+ def job_started(_type) = nil
53
+ def job_finished(_type) = nil
54
+
55
+ def blank_line = $stdout.puts
56
+
57
+ # Default fallback. CLI overrides to render the
58
+ # `┄ HH:MM · mode → plan ┄` free-line variant.
59
+ def mode_changed(name, previous: nil)
60
+ arrow = previous && previous != name ? " #{previous} → #{name}" : " #{name}"
61
+ puts_colored(color_for(:muted), " ⟳ mode#{arrow}")
62
+ end
63
+
64
+ private
65
+
66
+ # Subclasses override to map a semantic role to a Pastel method symbol.
67
+ # @param role [Symbol] e.g. :info, :success, :warning, :error, :tool, :muted
68
+ # @return [Symbol, nil] Pastel method name, or nil to skip coloring
69
+ def color_for(_role)
70
+ nil
71
+ end
72
+
73
+ def puts_colored(color, text)
74
+ line = color ? @pastel.send(color, text) : text
75
+ $stdout.puts line
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bottom_composer"
4
+
5
+ module Rubino
6
+ module UI
7
+ # The /probe (#58) and /agents probe (#146) wait indicator: reuse the UI's
8
+ # thinking-row machinery (UI::CLI) while a billed ephemeral peek runs, and
9
+ # stay silent on Null/API adapters or piped stdout. Mixed into both the
10
+ # CLI::ChatCommand and Commands::Executor probe paths, which carried a
11
+ # byte-identical pair of guards.
12
+ #
13
+ # The peek is SYNCHRONOUS (seconds of model wait) and runs at the idle REPL
14
+ # with no live composer, so keystrokes typed during it used to smear onto the
15
+ # thinking row — there was no `❯` to echo into (#221). To own input the same
16
+ # way a streaming turn does, the wait now runs under a transient bottom
17
+ # composer: it draws a real `❯`, its reader thread buffers keystrokes, and
18
+ # the thinking ticker paints into its transient row via #set_partial (the
19
+ # #169 seam) instead of colliding with the input. Anything typed is recovered
20
+ # into the next idle prompt's draft, so input is never lost.
21
+ module ProbeWaitIndicator
22
+ def probe_thinking_started(ui)
23
+ return unless $stdout.tty? && ui.respond_to?(:thinking_started)
24
+
25
+ # Own the bottom of the screen for the wait so typed input lands in a
26
+ # visible `❯` instead of smearing onto the ticker row (#221). Started
27
+ # BEFORE the ticker so #thinking_started paints into the composer's
28
+ # transient row. A standalone editor (no completion/history wiring) — it
29
+ # only needs to echo input and host the ticker. Best-effort: a terminal
30
+ # that can't host a raw composer (no real device, sized double) just
31
+ # keeps the old ticker-only wait — never break the probe.
32
+ @probe_composer = build_probe_composer
33
+ ui.thinking_started
34
+ end
35
+
36
+ def probe_thinking_finished(ui)
37
+ ui.thinking_finished if ui.respond_to?(:thinking_finished)
38
+ composer = @probe_composer
39
+ @probe_composer = nil
40
+ return unless composer
41
+
42
+ # Hand whatever the user typed during the wait to the next idle prompt as
43
+ # a draft, so the buffered text reappears in `❯` after the peek (no data
44
+ # loss). Read the buffer before #stop tears the composer down and
45
+ # restores cooked mode + a clean line. Best-effort: tearing down a
46
+ # degraded composer must never break the probe (mirrors the start guard).
47
+ typed = begin
48
+ composer.buffer.dup
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ begin
53
+ composer.stop
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ ui.stash_probe_draft(typed) if typed && !typed.empty? && ui.respond_to?(:stash_probe_draft)
58
+ end
59
+
60
+ private
61
+
62
+ # Builds and starts the transient probe composer, or nil when the terminal
63
+ # can't host a raw input reader (no real stdin/stdout device — piped, or a
64
+ # test double). Best-effort: a failed start degrades to the old ticker-only
65
+ # wait rather than breaking the probe.
66
+ def build_probe_composer
67
+ return nil unless $stdin.respond_to?(:tty?) && $stdin.tty?
68
+
69
+ BottomComposer.new(input_queue: [], echo: :prompt).start
70
+ rescue StandardError
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubino
6
+ module UI
7
+ # The {BottomComposer}'s stack of EXPLICITLY-queued messages (Alt+Enter /
8
+ # "/queued <msg>") awaiting their turn, rendered as live "⏳ queued: <msg>"
9
+ # rows above the input — never committed to scrollback. Wraps the list the
10
+ # chat loop SHARES across the per-turn composers, so the indicator survives
11
+ # a composer teardown and is removed (and the item committed as a normal
12
+ # message) when the queued item's turn runs. Pure state + row formatting:
13
+ # the composer owns the render mutex and the redraw around every mutation.
14
+ class QueuedIndicators
15
+ # Hard cap on the visible rows so a burst of explicit queues can never
16
+ # push the prompt off-screen. Beyond the cap, a dim count row stands in
17
+ # for the overflow.
18
+ MAX_ROWS = 4
19
+
20
+ # @param list [Array<String>] the shared (or private) pending stack.
21
+ def initialize(list)
22
+ @list = list
23
+ end
24
+
25
+ def any?
26
+ @list.any?
27
+ end
28
+
29
+ # Add +msg+ to the pending stack. +front+ jumps the queue (the
30
+ # interrupt-by-default Enter): its indicator leads the pending rows so
31
+ # the visible order matches the run order (#129).
32
+ def push(msg, front: false)
33
+ front ? @list.unshift(msg) : @list.push(msg)
34
+ end
35
+
36
+ # Remove the FIRST pending indicator matching +msg+ (the chat loop calls
37
+ # through when the queued item's turn starts). Returns the removed
38
+ # message, or nil when none matched.
39
+ def remove(msg)
40
+ idx = @list.index(msg)
41
+ return unless idx
42
+
43
+ @list.delete_at(idx)
44
+ end
45
+
46
+ # The "⏳ queued: <msg>" indicator rows for the pending stack, in
47
+ # submission order. House grammar: the ⏳ glyph, dim. Capped to MAX_ROWS
48
+ # with a dim "┄ +N more queued ┄" overflow row.
49
+ def rows
50
+ return [] if @list.empty?
51
+
52
+ shown = @list.first(MAX_ROWS)
53
+ rows = shown.map { |msg| pastel.dim("⏳ queued: #{msg}") }
54
+ overflow = @list.size - shown.size
55
+ rows << pastel.dim("┄ +#{overflow} more queued ┄") if overflow.positive?
56
+ rows
57
+ end
58
+
59
+ private
60
+
61
+ def pastel
62
+ @pastel ||= Pastel.new
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubino
6
+ module UI
7
+ # Formats the dim one-line status bar the {BottomComposer} renders BELOW
8
+ # the pinned input row:
9
+ #
10
+ # default · minimax-m3 · ctx ~8.4k/64k (13%)
11
+ #
12
+ # Content: the session MODE leads (the prompt chip moved here in the
13
+ # Rail-rubino redesign — the prompt is a constant "▍❯ "), then the
14
+ # optional branch / active-skill tokens, the resolved model id and the
15
+ # context saturation — the SAME estimate the compaction logic runs on
16
+ # (Context::TokenBudget: chars/4 over the session messages, window from
17
+ # `model.context_length` / `context.max_tokens` with the TokenBudget
18
+ # default). The caller passes the values; this module only formats. ONE
19
+ # encoding of the saturation (P9): the used/window pair, with the
20
+ # percentage in parentheses — omitted entirely below 1% so a fresh
21
+ # session doesn't carry a "(0%)". With no usable window the bar degrades
22
+ # to `~8.4k tok`.
23
+ #
24
+ # Color: everything dim, except the mode token when it carries risk
25
+ # (plan yellow, yolo red — subtle, no bold) and the percentage when high
26
+ # — yellow ≥ 70%, red ≥ 90% — matching the existing pastel usage. Each
27
+ # segment is styled SEPARATELY (never a colored span nested inside one
28
+ # dim span) so a colored reset can't strip the dim from the rest of the
29
+ # line. The single leading space tucks the bar one column in, under the
30
+ # input rail.
31
+ module StatusBar
32
+ WARN_PCT = 70
33
+ CRIT_PCT = 90
34
+
35
+ module_function
36
+
37
+ # The styled status line. +chips+ carries the leading session-context
38
+ # tokens — :mode (the mode token shown FIRST; plan/yolo carry their
39
+ # accent), :branch (the short id after a `/branch` fork) and :skill
40
+ # (the active skill, rendered "skill <name>") — each omitted when
41
+ # nil/absent, so callers without that context get the bare
42
+ # model-and-ctx bar. +tokens+ is the estimated tokens in the
43
+ # conversation; +window+ the model's context window (nil/0 ⇒ unknown,
44
+ # percentage omitted). Returns a string ready to draw (leading indent
45
+ # included) — the composer clamps/omits it per terminal width.
46
+ def render(model:, tokens:, window: nil, chips: {}, pastel: Pastel.new)
47
+ segments = chip_segments(chips, pastel)
48
+ segments << pastel.dim(model.to_s)
49
+ if window.to_i.positive?
50
+ pct = (tokens.to_i * 100.0 / window.to_i).round
51
+ ctx = pastel.dim("ctx ~#{abbreviate(tokens)}/#{abbreviate(window)}")
52
+ ctx += " #{pastel.dim("(")}#{percent_segment(pct, pastel)}#{pastel.dim(")")}" if pct >= 1
53
+ segments << ctx
54
+ else
55
+ segments << pastel.dim("~#{abbreviate(tokens)} tok")
56
+ end
57
+ " #{segments.join(pastel.dim(" · "))}"
58
+ end
59
+
60
+ # The leading session-context segments, in fixed order: mode, branch,
61
+ # skill (each omitted when absent). The mode token is dim for default
62
+ # and carries a subtle color accent when the mode carries risk — plan
63
+ # yellow, yolo red (the same red as the input rail's brand accent).
64
+ def chip_segments(chips, pastel)
65
+ segments = []
66
+ segments << mode_segment(chips[:mode], pastel) if chips[:mode]
67
+ segments << pastel.dim("branch:#{chips[:branch]}") if chips[:branch]
68
+ segments << pastel.dim("skill #{chips[:skill]}") if chips[:skill]
69
+ segments
70
+ end
71
+
72
+ def mode_segment(mode, pastel)
73
+ case mode.to_s
74
+ when "plan" then pastel.yellow("plan")
75
+ when "yolo" then pastel.red("yolo")
76
+ else pastel.dim(mode.to_s)
77
+ end
78
+ end
79
+
80
+ # The "<pct>%" segment: dim normally, yellow from WARN_PCT, red from
81
+ # CRIT_PCT — the at-a-glance compaction warning.
82
+ def percent_segment(pct, pastel)
83
+ text = "#{pct}%"
84
+ return pastel.red(text) if pct >= CRIT_PCT
85
+ return pastel.yellow(text) if pct >= WARN_PCT
86
+
87
+ pastel.dim(text)
88
+ end
89
+
90
+ # Human token count: 842 → "842", 8421 → "8.4k", 128_000 → "128k".
91
+ def abbreviate(count)
92
+ n = count.to_i
93
+ return n.to_s if n < 1000
94
+
95
+ k = n / 1000.0
96
+ k >= 100 ? "#{k.round}k" : format("%.1fk", k).sub(".0k", "k")
97
+ end
98
+ end
99
+ end
100
+ end