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,1674 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "io/console"
5
+
6
+ module Rubino
7
+ module CLI
8
+ # Interactive and non-interactive chat session command.
9
+ # Supported flags:
10
+ # -q/--query one-shot non-interactive prompt
11
+ # -c/--continue resume most recent session
12
+ # -r/--resume resume session by ID or title
13
+ # --provider override provider
14
+ # --yolo skip all approval prompts
15
+ # --max-turns override max tool iterations
16
+ # --ignore-rules skip AGENTS.md and context files
17
+ class ChatCommand
18
+ include Rubino::UI::ProbeWaitIndicator
19
+
20
+ # Window (seconds) for the Aider-style double-tap: a second Ctrl+C
21
+ # within this of the first re-raises so the user can actually quit.
22
+ DOUBLE_TAP_SECONDS = 2.0
23
+
24
+ # Picker snippet length — enough to recognize the message at a glance.
25
+ REWIND_SNIPPET_CHARS = 60
26
+
27
+ # The confirm press must come after a deliberate beat (a blind mash
28
+ # re-arms instead of confirming — the exact failure mode of #152 was
29
+ # 2-5 quick presses while watching the stream) and before the arm goes
30
+ # stale (the toast is long gone; a lone later press must re-confirm).
31
+ YOLO_CONFIRM_MIN_SECONDS = 0.3
32
+ YOLO_CONFIRM_WINDOW_SECONDS = 5.0
33
+
34
+ PROMPT_CARET = "❯"
35
+ PROMPT_RAIL = "▍"
36
+
37
+ def initialize(options = {})
38
+ @options = options
39
+ end
40
+
41
+ def execute
42
+ ensure_setup!
43
+ ensure_model_configured!
44
+
45
+ query = opt(:query) || opt(:q)
46
+ if query
47
+ run_oneshot(query)
48
+ else
49
+ run_interactive
50
+ end
51
+ rescue Rubino::AmbiguousSessionError, Rubino::SessionError => e
52
+ # Render session-resolution errors as a clean stderr message + non-zero
53
+ # exit, not a Ruby stack trace. AmbiguousSessionError's message
54
+ # already includes the candidate list, so just print it.
55
+ warn e.message
56
+ exit(1)
57
+ end
58
+
59
+ private
60
+
61
+ # --- Collaborators (#17): cohesive REPL concerns extracted into their own
62
+ # classes (image inbox / session resolution + replay / idle card host);
63
+ # ChatCommand orchestrates them around the turn loop. ---
64
+
65
+ def image_inbox
66
+ @image_inbox ||= Chat::ImageInbox.new
67
+ end
68
+
69
+ # The per-session paste store behind the file-backed paste pipeline:
70
+ # large pastes collapse to "[Pasted text #N +M lines]" placeholders in
71
+ # the composer and are expanded back to the full body (or to a
72
+ # paste_N.txt read-tool pointer for oversized ones) in #run_turn, the
73
+ # message-build seam. Shared across the per-turn composers, like
74
+ # #pending_queued; /clear-images never touches it (different inbox).
75
+ def paste_store
76
+ @paste_store ||= Rubino::UI::PasteStore.new
77
+ end
78
+
79
+ def session_resolver
80
+ @session_resolver ||= Chat::SessionResolver.new(@options)
81
+ end
82
+
83
+ def idle_cards
84
+ @idle_cards ||= Chat::IdleCardHost.new
85
+ end
86
+
87
+ def bang_shell
88
+ @bang_shell ||= Chat::BangShell.new
89
+ end
90
+
91
+ # --- One-shot mode ---
92
+
93
+ def run_oneshot(query)
94
+ apply_yolo! if opt(:yolo)
95
+
96
+ # Structured JSON log lines (llm.retry & friends) must never contaminate
97
+ # the one-shot stdout (#99): `answer=$(rubino prompt ...)` pipes stdout,
98
+ # so a warn event would interleave JSON noise with the answer. Route the
99
+ # logger to stderr for the whole one-shot run — the diagnostic twin of
100
+ # the interactive REPL's redirect-to-file (#125). Restored in the ensure
101
+ # so embedders/tests sharing the memoized logger are unaffected.
102
+ prev_log_io = redirect_logger_to_stderr
103
+
104
+ # Surface the resolved model (and any unknown-id warning) before the
105
+ # answer (#142). In one-shot mode there is no chat header, so without
106
+ # this a typo'd `-m` silently runs the wrong/forced-through model with
107
+ # zero feedback. Echo only when the user passed an explicit override so
108
+ # we don't add noise to the default-model happy path.
109
+ announce_resolved_model
110
+
111
+ # Seed --add-dir roots; one-shot mode is non-interactive so the trust
112
+ # prompt is skipped (an untrusted dir simply runs in restricted mode).
113
+ setup_workspace_and_trust!(Rubino.ui, interactive: false)
114
+
115
+ # Headless/scripted attachment: honour @image tokens in the prompt AND
116
+ # explicit --image PATH flags, both routed to the native vision slot
117
+ # (image_paths) — the same path the interactive REPL uses. Without this,
118
+ # `-q` / `prompt` / `chat "..."` had no way to attach an image at all
119
+ # (attachment was REPL-only); automation, jobs and tests can now drive it.
120
+ text, image_paths = Chat::ImageInbox.resolve_oneshot(query, opt(:image))
121
+
122
+ runner = build_runner(session_id: session_resolver.resolve_session_id, ui: UI::Null.new)
123
+
124
+ # Use run! (not run) so a model/credential failure PROPAGATES instead of
125
+ # being swallowed into a nil and printed as an empty line with exit 0.
126
+ # A brand-new user with no key would otherwise see ~80s of silent retries
127
+ # then an empty prompt and a success exit (#93) — here we surface the
128
+ # actionable error to stderr and exit non-zero so automation/the user can
129
+ # actually tell it failed.
130
+ announce_attachment_upload(image_paths)
131
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
132
+ response = runner.run!(text, image_paths: image_paths)
133
+
134
+ print_oneshot_answer(response.to_s)
135
+ $stdout.flush
136
+
137
+ # Fire the turn-finished attention seam for headless runs (#215). A
138
+ # scripted `rubino prompt`/-q run never goes through UI::CLI#turn_finished
139
+ # (it uses UI::Null), so the documented notifications.command hook —
140
+ # exactly what automation wants to ping a human on completion — never
141
+ # fired. Drive the same notifier here: the BELL self-suppresses into a
142
+ # pipe (bell_sink is nil off a TTY), so only the detached command hook
143
+ # actually does anything headless, which is the intent. Best-effort: a
144
+ # notification must never fail the run or contaminate the piped answer.
145
+ notify_oneshot_finished(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
146
+ # rubocop:disable Lint/ShadowedException -- Interrupt is listed explicitly (doc value), though SignalException covers it
147
+ rescue Rubino::Interrupted, Interrupt, SystemExit, SignalException
148
+ raise
149
+ # rubocop:enable Lint/ShadowedException
150
+ rescue Exception => e # rubocop:disable Lint/RescueException
151
+ warn "rubino: #{e.message}"
152
+ exit(1)
153
+ ensure
154
+ restore_logger(prev_log_io)
155
+ end
156
+
157
+ # One deterministic status line before a request that carries attachments
158
+ # (#101): a multi-MB upload can stall for tens of seconds with zero
159
+ # feedback in one-shot mode. Goes to stderr so the piped stdout answer
160
+ # stays clean.
161
+ def announce_attachment_upload(image_paths)
162
+ return if image_paths.empty?
163
+
164
+ mb = (image_paths.sum { |p| File.size?(p).to_i } / 1_048_576.0).round(1)
165
+ label = image_paths.size == 1 ? "image" : "#{image_paths.size} images"
166
+ warn "sending #{label} (#{mb} MB)…"
167
+ end
168
+
169
+ # Prints the one-shot answer. On a real TTY the answer goes through the
170
+ # SAME markdown pipeline interactive chat uses (UI::CLI#assistant_text →
171
+ # MarkdownRenderer: styled headings/bold/code, width-fit tables, wrapping)
172
+ # so `prompt`/-q doesn't dump literal `**`/`|---|` markdown at a human
173
+ # (#69). When stdout is NOT a TTY the raw text is kept byte-for-byte —
174
+ # `answer=$(rubino prompt ...)` and downstream tools want plain text, and
175
+ # diagnostics already route to stderr (#99) so the pipe stays clean.
176
+ def print_oneshot_answer(text)
177
+ if $stdout.respond_to?(:tty?) && $stdout.tty?
178
+ UI::CLI.new.assistant_text(text)
179
+ else
180
+ $stdout.puts text
181
+ end
182
+ end
183
+
184
+ # Drives the turn-finished attention notifier after a one-shot run (#215),
185
+ # so the documented notifications.command hook fires for headless/scripted
186
+ # `rubino prompt` / -q completions too — the seam automation uses to ping a
187
+ # human. The notifier's own min_turn_seconds gate still applies (quick runs
188
+ # stay silent) and the bell self-suppresses into a pipe, so off a TTY only
189
+ # the detached command hook runs. Wholly best-effort: a notification detail
190
+ # must never fail the run.
191
+ def notify_oneshot_finished(elapsed)
192
+ UI::Notifier.new.turn_finished(elapsed)
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ # Routes the structured logger to stderr for the one-shot run (#99).
198
+ # Returns the previous sink IO to restore on exit; nil (no-op) on failure —
199
+ # a logging-destination detail must never break the run.
200
+ def redirect_logger_to_stderr
201
+ Rubino.logger.reopen($stderr)
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ # --- Interactive mode ---
207
+ #
208
+ # One path for TTY and non-TTY: inline streaming to stdout.
209
+ # No fullscreen TUI. Native terminal scroll, copy, and shell
210
+ # history all keep working because we never leave the main screen.
211
+
212
+ def run_interactive
213
+ apply_yolo! if opt(:yolo)
214
+
215
+ ui = Rubino.ui
216
+
217
+ # Capture git context before creating runner (session not yet available)
218
+ git = git_context
219
+
220
+ ui.blank_line
221
+ ui.info("rubino")
222
+ ui.status("workspace #{collapse_home(Dir.pwd)}")
223
+ if git
224
+ dirty_mark = git[:dirty] ? " *" : ""
225
+ ui.status("branch #{git[:branch]}#{dirty_mark} @ #{git[:sha]}")
226
+ end
227
+ ui.status("model #{model_name}")
228
+ warn_unknown_model if model_override_given?
229
+ # Update-available notice (interactive only): one dim line, sourced
230
+ # purely from the local cache so it never slows boot. The network
231
+ # refresh below is detached/rescued and only freshens the cache for the
232
+ # NEXT boot. No-ops entirely until rubino-agent is published.
233
+ note = Rubino::UpdateCheck.notice_from_cache
234
+ ui.status(note) if note
235
+ Rubino::UpdateCheck.refresh_async_if_stale
236
+ ui.blank_line
237
+
238
+ # Seed --add-dir roots and run the folder-trust gate before any turn
239
+ # assembles a system prompt that could pull in an untrusted dir's
240
+ # AGENTS.md / skills.
241
+ setup_workspace_and_trust!(ui, interactive: true)
242
+
243
+ runner = build_runner(session_id: session_resolver.resolve_session_id(auto_resume: true), ui: ui)
244
+
245
+ # Scope tier-2 paste files under the CURRENT session's artifacts dir
246
+ # (<home>/sessions/<id>/paste_N.txt). The closure reads the local
247
+ # `runner` at write time, so /new //sessions //branch — which reassign
248
+ # it — re-scope the files without re-wiring.
249
+ paste_store.session_source = -> { runner.session[:id] }
250
+
251
+ # The runner already announced the session ("New/Resuming session: <id>");
252
+ # re-printing the full uuid here was the third copy of the same id on boot
253
+ # (#82). The short id is enough; the full one lives in /status.
254
+
255
+ # Best-effort: a closed terminal / kill marks the session ended too (#100).
256
+ prev_signal_traps = install_session_end_traps(runner)
257
+
258
+ cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
259
+ cmd_loader = Rubino::Commands::Loader.new
260
+
261
+ # The bottom composer is now the SINGLE input path (idle AND in-turn): one
262
+ # pinned-bottom editor with full editing parity, so output/reasoning/
263
+ # footers commit ABOVE the prompt and out-of-band keys can't smear the
264
+ # stream. Build its shared completion source + history once; #next_input
265
+ # routes the idle prompt through a composer wired with them. A plain
266
+ # cooked readline remains the fallback for non-TTY / piped / -q input.
267
+ @completion_source = Chat::CompletionBuilder.new(cmd_loader).build
268
+ @input_history = Rubino::UI::InputHistory.new
269
+
270
+ if session_resolver.resuming_session?
271
+ # On a bare-chat auto-resume (#99) tell the user, clearly and once,
272
+ # that we picked up their last session and how to start fresh —
273
+ # otherwise the continuation is silent and looks like a fresh boot.
274
+ session_resolver.print_auto_resume_line(ui, runner.session) if session_resolver.auto_resumed_session
275
+ session_resolver.print_session_history(ui, runner.session[:id])
276
+ else
277
+ # First-run welcome panel: the same assembler /status uses, trimmed.
278
+ Rubino::Commands::Executor.welcome(runner: runner, ui: ui)
279
+ end
280
+
281
+ # `chat --image/-i` without -q: stage the flag paths into the SAME
282
+ # pending-attachment inbox @image tokens, /paste and dropped paths fill
283
+ # (#160) — the flag used to be consumed only by the one-shot path, so
284
+ # in interactive mode it was silently dropped.
285
+ stage_flag_images(ui)
286
+
287
+ # Steering: lines the user types *during* a turn are captured by the
288
+ # background reader (see #run_turn) and parked here. At the next turn
289
+ # boundary we drain them and they become the next prompt, so a message
290
+ # typed while the agent was working is answered as the next turn with
291
+ # no copy/paste — instead of blocking on a fresh readline.
292
+ input_queue = Rubino::Interaction::InputQueue.new
293
+
294
+ # Drive the turn-scoped status row from bus events the UI doesn't see
295
+ # directly: MESSAGE_COMPLETED (a streamed block ended — commit its tail
296
+ # and resume the row between blocks, the P4 inter-tool gap) and
297
+ # JOB_STARTED/JOB_FINISHED (the post-turn inline jobs spending aux-LLM
298
+ # seconds after the footer — the P6 "polishing" phase). Both arrive on
299
+ # the process-global bus the interactive runner and the inline job
300
+ # runner emit on. Best-effort: a UI hiccup must never fail the source.
301
+ subscribe_status_row_events(ui)
302
+
303
+ # Reset the shared explicit-queue stack for this interactive session (see
304
+ # #pending_queued): live "⏳ queued: <msg>" rows the composers render and
305
+ # the loop commits as normal messages when their turn runs.
306
+ @pending_queued = []
307
+
308
+ # Keep structured JSON log lines OUT of the raw-mode TUI (#125): for the
309
+ # whole interactive session the logger writes to a file in the logs dir
310
+ # instead of the terminal $stdout the renderer owns. A warn/info event
311
+ # (e.g. a network blip while a background subagent runs) would otherwise
312
+ # be dumped as raw JSON into the rendered conversation, corrupting the
313
+ # bottom-composer frame. Restored on teardown. Logs are not lost — they
314
+ # go to the file.
315
+ prev_log_io = redirect_logger_to_file
316
+
317
+ interacted = false
318
+ begin
319
+ loop do
320
+ input = next_input(input_queue, runner)
321
+ # Esc-Esc rewind: the idle read forked the session at the picked
322
+ # message and parked the fork's runner — adopt it BEFORE dispatch
323
+ # so the edited message runs as the next turn on the fork (the
324
+ # same swap-in-place /branch and /compact do).
325
+ if (rewound = @rewound_runner)
326
+ @rewound_runner = nil
327
+ runner = rewound
328
+ cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
329
+ end
330
+ if input.nil? || exit_command?(input)
331
+ break if confirm_quit?(ui)
332
+
333
+ next
334
+ end
335
+ next if input.strip.empty?
336
+
337
+ input = input.strip
338
+
339
+ # Image-input commands manipulate the pending-attachment state local
340
+ # to this REPL (not the agent), so they're handled here before the
341
+ # slash dispatcher. `/paste` grabs a clipboard image; `/clear-images`
342
+ # drops anything queued.
343
+ if image_inbox.handle_image_command(input, ui)
344
+ commit_queued_dispatch
345
+ next
346
+ end
347
+
348
+ # Pull any image references (@image, dropped/quoted path) out of the
349
+ # line into image_paths (the native vision slot); the rest stays text.
350
+ # An image-only line STAGES the attachment instead of submitting an
351
+ # empty turn (#100): the in-prompt hint promises a "sent with your
352
+ # next message (/clear-images to drop)" window, so honour it for
353
+ # @image/dropped paths the same as /paste — the image goes out with
354
+ # the next message that carries text.
355
+ input = image_inbox.extract_images!(input, ui)
356
+ if input.empty?
357
+ commit_queued_dispatch
358
+ next
359
+ end
360
+
361
+ # A leading `? ` is the one-keystroke ephemeral probe (Option A of
362
+ # the locked UX): the rest of the line is a side-question answered
363
+ # from the session context, rendered in a dim aside, then DISCARDED
364
+ # — nothing is written to the transcript. Handled BEFORE slash
365
+ # dispatch so `? /foo` is still a probe about a literal `/foo`.
366
+ if (question = probe_question(input))
367
+ commit_queued_dispatch
368
+ run_probe(runner, question, ui)
369
+ next
370
+ end
371
+
372
+ # A leading `!` is the human shell escape (Claude Code's bash
373
+ # mode): run the rest of the line in the user's shell NOW — no
374
+ # approval, the human typed it — stream the output into the
375
+ # transcript, then inject command + output into the session as
376
+ # user-role <bash-input>/<bash-stdout><bash-stderr> messages so
377
+ # the model can reference them next turn. Handled BEFORE slash
378
+ # dispatch so `!` always wins. :ran counts as interaction (the
379
+ # session now has messages worth a resume hint); a bare-`!`
380
+ # usage line (:handled) does not.
381
+ case bang_shell.handle(input, runner, ui)
382
+ when :ran
383
+ interacted = true
384
+ commit_queued_dispatch
385
+ next
386
+ when :handled
387
+ commit_queued_dispatch
388
+ next
389
+ end
390
+
391
+ if input.start_with?("/")
392
+ # A dequeued line that resolves to a SLASH COMMAND never reaches
393
+ # #run_turn, so #commit_queued_prompt would never fire for it and
394
+ # its live "⏳ queued:" row would leak across later prompts
395
+ # (#192). Commit it here — echo + drop the indicator — before the
396
+ # command runs, whatever the dispatch result is.
397
+ commit_queued_dispatch
398
+ result = cmd_executor.try_execute(input)
399
+ case result
400
+ when :exit then break
401
+ when :handled then next
402
+ when Hash
403
+ if result[:probe]
404
+ # /probe <text>: same ephemeral side-inference as the `? `
405
+ # prefix, then discard. The teaching-only bare /probe returned
406
+ # :handled above, so this always carries a question.
407
+ run_probe(runner, result[:probe], ui)
408
+ next
409
+ end
410
+ if result[:branch]
411
+ # /branch [name]: fork the current session here into a new
412
+ # saved one (inheriting context + any preceding probe) and
413
+ # SWITCH into it, leaving the original intact.
414
+ runner = branch_runner(ui, runner, result[:title])
415
+ cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
416
+ next
417
+ end
418
+ if result[:resume_session_id]
419
+ # /sessions <id|title>: rebuild the runner on the chosen
420
+ # session in place and replay its history, then go back to the
421
+ # prompt — no process restart needed. Leaving a branch (e.g.
422
+ # back to the parent) drops the branch token from the status bar.
423
+ @branch_short_id = nil
424
+ runner = resume_runner(ui, result[:resume_session_id])
425
+ cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
426
+ next
427
+ end
428
+ if result[:compact_into]
429
+ # /compact: the compactor wrote head+summary+tail into a
430
+ # child session (the source is now status "compacted") —
431
+ # swap the runner into the child WITHOUT replaying history,
432
+ # so the next turn runs on the compacted context.
433
+ runner = build_runner(session_id: result[:compact_into], ui: ui)
434
+ cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
435
+ next
436
+ end
437
+ if result[:new_session]
438
+ # /new: end the current session and rebuild the runner on a
439
+ # fresh one in place — the counterpart to the bare-chat resume.
440
+ @branch_short_id = nil
441
+ runner.end_session!
442
+ runner = fresh_runner(ui)
443
+ cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
444
+ interacted = false
445
+ next
446
+ end
447
+ interacted = true
448
+ run_turn(runner, result[:prompt], ui, input_queue)
449
+ else interacted = true
450
+ run_turn(runner, input, ui, input_queue)
451
+ end
452
+ else
453
+ interacted = true
454
+ run_turn(runner, input, ui, input_queue)
455
+ end
456
+ end
457
+ rescue Interrupt
458
+ # A double-tap Ctrl+C inside run_turn re-raises to break out of the
459
+ # REPL — exit cleanly instead of dumping a signal backtrace.
460
+ ensure
461
+ restore_signal_traps(prev_signal_traps)
462
+ restore_logger(prev_log_io)
463
+ end
464
+
465
+ # Mark the session ended on a clean teardown (#100) so it stops showing
466
+ # as "active" forever and cleanup/--continue can tell finished from live.
467
+ runner.end_session!
468
+
469
+ ui.blank_line
470
+ ui.info("Session ended.")
471
+ session_resolver.print_resume_hint(ui, runner.session) if interacted
472
+ end
473
+
474
+ # Best-effort: on a terminal close (SIGHUP) or kill (SIGTERM) mark the
475
+ # current session ended too, so a closed window doesn't leave it looking
476
+ # active (#100). The handler must stay trap-safe — one synchronous DB
477
+ # update then exit; no I/O, no locking. Returns the previous handlers so
478
+ # they can be restored on the normal exit path. nil for signals this
479
+ # platform doesn't define (e.g. SIGHUP on Windows).
480
+ def install_session_end_traps(runner)
481
+ %w[HUP TERM].each_with_object({}) do |sig, prev|
482
+ next unless Signal.list.key?(sig)
483
+
484
+ prev[sig] = Signal.trap(sig) do
485
+ runner.end_session!
486
+ exit(0)
487
+ end
488
+ rescue ArgumentError
489
+ nil # signal not supported on this platform
490
+ end
491
+ end
492
+
493
+ def restore_signal_traps(prev)
494
+ return unless prev
495
+
496
+ prev.each { |sig, handler| Signal.trap(sig, handler || "DEFAULT") }
497
+ rescue ArgumentError
498
+ nil
499
+ end
500
+
501
+ # Install the idle-prompt SIGINT trap (BH-2). The block is the whole
502
+ # handler body and MUST be trap-safe — the caller passes one that only
503
+ # flips a plain flag (no Mutex, no I/O), exactly like the during-turn INT
504
+ # trap. Returns the previous handler so #restore_idle_int can put it back.
505
+ # nil (no trap installed) on a platform without SIGINT.
506
+ def trap_idle_int(&)
507
+ Signal.trap("INT", &)
508
+ rescue ArgumentError
509
+ nil
510
+ end
511
+
512
+ # Restore whatever INT handler was in place before the idle read armed its
513
+ # own (the session-end / default handler), so the trap never leaks past the
514
+ # idle prompt into a turn (which installs its own double-tap INT trap).
515
+ def restore_idle_int(prev)
516
+ Signal.trap("INT", prev || "DEFAULT")
517
+ rescue ArgumentError
518
+ nil
519
+ end
520
+
521
+ # Routes the structured logger to a file for the interactive session so
522
+ # JSON log lines never reach the terminal $stdout the TUI renders into
523
+ # (#125). Returns the previous sink IO to restore on exit; nil (no-op,
524
+ # logger untouched) if the file can't be opened — a logging-destination
525
+ # detail must never break the chat boot.
526
+ def redirect_logger_to_file
527
+ dir = File.expand_path(Rubino.configuration.dig("paths", "logs") || "~/.rubino/logs")
528
+ FileUtils.mkdir_p(dir)
529
+ file = File.open(File.join(dir, "rubino.log"), "a") # rubocop:disable Style/FileOpen -- the sink must outlive this method
530
+ file.sync = true
531
+ Rubino.logger.reopen(file)
532
+ rescue StandardError
533
+ nil
534
+ end
535
+
536
+ # Restores the logger's sink to whatever it was before the interactive
537
+ # session redirected it (typically $stdout). No-op when redirection was
538
+ # skipped (prev nil).
539
+ def restore_logger(prev)
540
+ return unless prev
541
+
542
+ Rubino.logger.reopen(prev)
543
+ rescue StandardError
544
+ nil
545
+ end
546
+
547
+ # Relays bus events into the turn-scoped status row. Subscribed once per
548
+ # interactive session on the process-global bus:
549
+ # MESSAGE_COMPLETED — the adapter closed one streamed content block;
550
+ # the UI commits the block's tail and resumes the row so the gap
551
+ # until the next tool/block isn't dead air (P4). Subagents run on
552
+ # their own per-task bus, so their blocks never reach this listener.
553
+ # JOB_STARTED/JOB_FINISHED — the inline post-turn jobs (memory extract,
554
+ # skill distill); the row shows "polishing · memory|skills" (P6).
555
+ # Every callback is fully rescued: a cosmetic repaint failure must never
556
+ # bubble into the emitter (it would fail the job / abort the stream).
557
+ def subscribe_status_row_events(ui)
558
+ return if @status_row_subscribed
559
+
560
+ @status_row_subscribed = true
561
+ bus = Rubino.event_bus
562
+ bus.on(Rubino::Interaction::Events::MESSAGE_COMPLETED) do |payload|
563
+ ui.stream_block_end(payload[:message_id]) if ui.respond_to?(:stream_block_end)
564
+ rescue StandardError
565
+ nil
566
+ end
567
+ bus.on(Rubino::Interaction::Events::JOB_STARTED) do |payload|
568
+ ui.job_started(payload[:type]) if ui.respond_to?(:job_started)
569
+ rescue StandardError
570
+ nil
571
+ end
572
+ bus.on(Rubino::Interaction::Events::JOB_FINISHED) do |payload|
573
+ ui.job_finished(payload[:type]) if ui.respond_to?(:job_finished)
574
+ rescue StandardError
575
+ nil
576
+ end
577
+ end
578
+
579
+ # Shared stack of EXPLICITLY-queued messages (Alt+Enter / "/queued"),
580
+ # rendered as live "⏳ queued: <msg>" rows above whichever composer is
581
+ # current (idle or in-turn) and removed — the item committed as a normal
582
+ # "<prompt><msg>" message — when its turn actually runs (see #run_turn).
583
+ # Memoized so it survives the per-turn composer teardown AND so unit tests
584
+ # that drive #read_idle_line / #start_composer directly (without going
585
+ # through #run_interactive) still get a real list, not nil.
586
+ def pending_queued
587
+ @pending_queued ||= []
588
+ end
589
+
590
+ # Next prompt for the REPL. If the user typed while the previous turn
591
+ # ran, those lines were parked in the InputQueue; consume them as the
592
+ # next prompt INSTEAD of blocking on a fresh readline. Each parked line is
593
+ # taken ONE at a time (FIFO) and run as its OWN turn (B4) — an
594
+ # interrupt-by-default Enter, an Alt+Enter, or a "/queued" each get their
595
+ # own turn in submission order, never coalesced into a single
596
+ # newline-joined message. The remaining queued items stay parked (their
597
+ # "⏳ queued:" indicators remain) and each runs on a later #next_input.
598
+ # When nothing is queued, fall back to the normal readline prompt.
599
+ # +runner+ (optional) feeds the status bar under the idle composer —
600
+ # model id + context saturation for the CURRENT session, refreshed at
601
+ # this turn boundary (and so on session resume/branch/new too, which all
602
+ # rebuild the runner before the next idle prompt).
603
+ # Pops any text the user typed during a synchronous /probe wait (#221),
604
+ # parked on the UI by ProbeWaitIndicator. nil on adapters that don't stash.
605
+ def probe_draft_stash
606
+ ui = Rubino.ui
607
+ ui.take_probe_draft if ui.respond_to?(:take_probe_draft)
608
+ end
609
+
610
+ def next_input(input_queue, runner = nil)
611
+ # Take the OLDEST parked line (FIFO). Mark it so #run_turn commits the
612
+ # normal "<prompt><line>" echo (and clears any "⏳ queued:" indicator)
613
+ # when this line runs. The rest stay queued for their own later turns.
614
+ queued = input_queue.shift
615
+ unless queued.nil?
616
+ @input_from_queue = [queued]
617
+ return queued
618
+ end
619
+ @input_from_queue = nil
620
+
621
+ # Carry over any draft the user typed into the bottom composer during the
622
+ # previous turn but never submitted (no Enter): the turn-scoped composer
623
+ # is torn down at turn end, so without this the in-progress text would
624
+ # vanish. Consume it once — the next idle prompt starts empty again.
625
+ draft = @pending_draft
626
+ @pending_draft = nil
627
+ # A synchronous /probe wait owned a transient composer to echo input
628
+ # (#221); anything typed there was parked on the UI and is restored into
629
+ # this prompt's draft so it reappears in `❯` after the peek.
630
+ if (probe_draft = probe_draft_stash) && !probe_draft.empty?
631
+ draft = draft.to_s.empty? ? probe_draft : "#{draft}#{probe_draft}"
632
+ end
633
+
634
+ # The bottom composer is the single idle input path on a real TTY: it
635
+ # pins the prompt at the bottom, owns its own raw reader (so keys can't
636
+ # smear the stream), updates the status bar's mode token LIVE on
637
+ # Shift+Tab, and hosts
638
+ # the background-subagent card region (F1) when children are live. The
639
+ # plain cooked readline is the fallback for non-TTY / piped / -q input.
640
+ if UI::BottomComposer.active?
641
+ read_idle_line(input_queue, draft, runner)
642
+ else
643
+ cooked_input(build_prompt, draft)
644
+ end
645
+ end
646
+
647
+ # Reads the user's next line at the IDLE prompt through the bottom composer
648
+ # — the single input path. The composer pins the prompt at the bottom and
649
+ # owns its own raw reader (full editing parity: arrows/Home/End/word-jump,
650
+ # ↑↓ history, /command + @file completion menu with immediate-Esc dismiss,
651
+ # cyan token highlight), updates the status bar's mode token LIVE on
652
+ # Shift+Tab, reveals
653
+ # reasoning on Ctrl+O, and hosts the collapsed subagent card region (F1)
654
+ # when background children are live — repaints land above the prompt and
655
+ # update in place, serialized through the composer's render mutex.
656
+ #
657
+ # We seed the carried-over draft, then BLOCK until the user submits a line,
658
+ # polling the same InputQueue the composer's reader pushes into (reusing the
659
+ # turn loop's hand-off). A half-typed, un-submitted draft is preserved in
660
+ # @pending_draft on teardown so it survives into the next prompt.
661
+ def read_idle_line(input_queue, draft, runner = nil)
662
+ # Esc-Esc rewind flag, flipped from the composer's reader thread and
663
+ # drained by the poll loop below — the same trap-safe split the idle
664
+ # Ctrl+C uses (the hook must never take the render mutex over there).
665
+ # Declared BEFORE the composer so the lambda captures this local.
666
+ # Without a runner there is no session to rewind, so no hook.
667
+ rewind_pending = false
668
+ composer = UI::BottomComposer.new(
669
+ input_queue: input_queue,
670
+ prompt: build_prompt,
671
+ rail: composer_rail,
672
+ on_ctrl_o: ctrl_o_handler,
673
+ on_mode_cycle: mode_cycle_handler(runner),
674
+ completion_source: @completion_source,
675
+ history: @input_history,
676
+ echo: :prompt,
677
+ pending_queued: pending_queued,
678
+ status_line: build_status_line(runner),
679
+ max_input_rows: Rubino.configuration.display_input_max_rows,
680
+ paste_store: paste_store,
681
+ on_double_esc: runner ? -> { rewind_pending = true } : nil
682
+ )
683
+ composer.start
684
+ # Route $stdout through the composer for the whole idle read — the SAME
685
+ # StdoutProxy swap a turn gets — so anything printed while the idle
686
+ # prompt is pinned (a background subagent's completion note, a late
687
+ # status line) commits ABOVE the input under the composer's render
688
+ # mutex instead of raw-painting over the prompt row (#169). The logger
689
+ # is forced to bind to the real IO first, exactly as in #start_composer.
690
+ real_stdout = $stdout
691
+ Rubino.logger
692
+ $stdout = UI::StdoutProxy.new(composer)
693
+ seed_draft(composer, draft)
694
+ idle_cards.paint
695
+ ticker = idle_cards.children_live? ? idle_cards.start_ticker(composer) : nil
696
+
697
+ # Gate idle Ctrl+C through the composer (BH-2): the composer runs under
698
+ # raw(intr: true), so a single Ctrl+C still raises SIGINT — which would
699
+ # otherwise hit the session-end / default handler and quit, silently
700
+ # discarding a typed draft. Trap INT here so a draft is never nuked: the
701
+ # trap body stays trap-safe (flip a flag only — Mutex#lock is forbidden
702
+ # in a trap, Ruby #14222), and the poll loop below performs the actual
703
+ # clear/hint/exit through the composer OUTSIDE trap context. Restored in
704
+ # the ensure so the trap never leaks past the idle read.
705
+ int_pending = false
706
+ prev_int = trap_idle_int { int_pending = true }
707
+
708
+ line = nil
709
+ loop do
710
+ # Drained the idle Ctrl+C the trap recorded: clear the draft (non-empty)
711
+ # or arm/confirm the two-tap exit (empty). Done here, not in the trap,
712
+ # so the render mutex is safe.
713
+ if int_pending
714
+ int_pending = false
715
+ break if composer.idle_interrupt(window: DOUBLE_TAP_SECONDS) == :exit
716
+ end
717
+
718
+ # Take ONE parked line (FIFO) so several items queued at idle each run
719
+ # as their OWN turn (B4), in submission order — never coalesced. The
720
+ # rest stay parked for the next #next_input / loop pass. Checked
721
+ # BEFORE the rewind flag: a line the user already submitted wins over
722
+ # an Esc-Esc that raced it (the pending rewind dies with the break —
723
+ # a picker must never pop over a turn that is about to start).
724
+ queued = input_queue.shift
725
+ unless queued.nil?
726
+ # An idle plain submit already echoed "<prompt><line>" at submit time;
727
+ # only an EXPLICITLY-queued item (Alt+Enter / "/queued" at idle, which
728
+ # carries a "⏳ queued:" indicator and no echo yet) needs run_turn to
729
+ # commit it as a normal message. Flag just that so a plain submit is
730
+ # never double-echoed.
731
+ @input_from_queue = pending_queued.include?(queued) ? [queued] : nil
732
+ line = queued
733
+ break
734
+ end
735
+
736
+ # Drain an Esc-Esc the reader recorded: open the rewind picker (it
737
+ # suspends the composer via run_in_terminal, so it must run on THIS
738
+ # thread, never the reader's). A pick forks the session, parks the
739
+ # fork in @rewound_runner for the REPL to adopt, and pre-fills the
740
+ # composer with the picked message; Esc-cancel changes nothing.
741
+ if rewind_pending
742
+ rewind_pending = false
743
+ if (rewound = handle_rewind(composer, runner, Rubino.ui))
744
+ runner = rewound
745
+ @rewound_runner = rewound
746
+ end
747
+ end
748
+ sleep(0.05)
749
+ end
750
+ line
751
+ ensure
752
+ restore_idle_int(prev_int)
753
+ ticker&.kill
754
+ ticker&.join
755
+ # Mirror #stop_composer: restore the real $stdout, then flush any held
756
+ # partial line through the still-live composer before tearing it down.
757
+ if real_stdout
758
+ proxy = $stdout
759
+ $stdout = real_stdout
760
+ proxy.finish if proxy.respond_to?(:finish)
761
+ end
762
+ if composer
763
+ pending = composer.buffer.to_s
764
+ @pending_draft = pending unless pending.strip.empty?
765
+ end
766
+ composer&.stop
767
+ end
768
+
769
+ # Seed a carried-over draft into the composer char-by-char so cursor/delete
770
+ # stay codepoint-granular (handle_key edits one codepoint at a time).
771
+ def seed_draft(composer, draft)
772
+ return if draft.nil? || draft.to_s.empty?
773
+
774
+ draft.to_s.each_char { |c| composer.handle_key(c) }
775
+ end
776
+
777
+ # Plain cooked prompt for non-TTY / piped / scripted interactive input,
778
+ # where the raw-mode composer can't run. Prints the prompt, reads one line,
779
+ # and pre-pends any carried-over draft so it isn't lost. nil on EOF.
780
+ def cooked_input(prompt, draft)
781
+ $stdout.print(prompt)
782
+ $stdout.flush
783
+ line = $stdin.gets
784
+ return nil if line.nil?
785
+
786
+ line = line.chomp
787
+ draft && !draft.to_s.empty? ? "#{draft}#{line}" : line
788
+ rescue IOError
789
+ nil
790
+ end
791
+
792
+ # Seeds the interactive pending-images inbox from --image/-i flag paths
793
+ # (#160); the attachment gate + indicator live in Chat::ImageInbox.
794
+ def stage_flag_images(ui)
795
+ image_inbox.stage_flag_images(opt(:image), ui)
796
+ end
797
+
798
+ # Wraps a single turn: Ctrl+C cancels the in-flight generation and
799
+ # drops back to the prompt, instead of killing the session.
800
+ #
801
+ # Aider-style double-tap (also how Codex/Claude Code behave): the first
802
+ # INT during a turn cooperatively cancels and prints a hint; a second
803
+ # INT within DOUBLE_TAP_SECONDS exits. The trap body must be trap-safe —
804
+ # it only flips the mutex-free CancelToken (see CancelToken: Mutex#lock
805
+ # is forbidden in a trap context, Ruby bug #14222) and reads/writes plain
806
+ # locals; no locking, no I/O, no re-entrant trap. The previous handler is
807
+ # always restored in +ensure+.
808
+ #
809
+ # Steering: when +input_queue+ is given and both ends are a TTY, a
810
+ # bottom-pinned composer (UI::BottomComposer) runs alongside the turn so
811
+ # the user can TYPE — with visible echo and backspace — while agent output
812
+ # streams ABOVE the input line into native scrollback. Completed lines are
813
+ # parked in the queue and picked up by the agent loop at the next ITERATION
814
+ # boundary (Phase 2 — between tool steps, never mid-tool); anything still
815
+ # queued after the turn ends falls back to #next_input as the next turn
816
+ # (the MVP boundary).
817
+ #
818
+ # Output coordination: while the composer is live, $stdout is swapped for a
819
+ # UI::StdoutProxy so the existing $stdout.print/puts call sites across
820
+ # UI::CLI / PrinterBase route their output through the composer's
821
+ # print_above instead of clobbering the input line — zero changes to those
822
+ # call sites. The proxy is torn down and the terminal restored to cooked
823
+ # mode in +ensure+ so raw mode / the swap never leak on a raise.
824
+ def run_turn(runner, prompt, ui, input_queue = nil)
825
+ # A real turn has happened, so any prior probe is no longer the
826
+ # "immediately-preceding interaction" — a later /branch must NOT fold it
827
+ # into the seed. Clear it here, the single chokepoint for real turns.
828
+ @last_probe = nil
829
+
830
+ # Consume the turn's queued image attachments (the native vision slot)
831
+ # so they're attached exactly once, not re-sent next turn.
832
+ image_paths = image_inbox.take!
833
+
834
+ # The message-build seam of the paste pipeline: COLLECT each
835
+ # "[Pasted text #N +M lines]" placeholder's full body (or the paste_N.txt
836
+ # read-tool pointer for oversized ones) WITHOUT mutating the prompt. The
837
+ # placeholder stays in the prompt — the message PERSISTED to the session
838
+ # keeps it, so live echo AND resume replay show the compact token (#213)
839
+ # — while the expansion map rides alongside as metadata, expanded into
840
+ # the MODEL-FACING content by Message#to_context. Queued (Alt+Enter) and
841
+ # history-recalled drafts collect here too, whichever turn they run as.
842
+ paste_expansions = paste_store.expansions_in(prompt)
843
+
844
+ # The interim idle-key GATE is retired: the bottom composer is now the
845
+ # single input path and serializes every above-line write through its
846
+ # render mutex, so Shift+Tab (mode footer) and Ctrl+O (reveal reasoning)
847
+ # commit cleanly ABOVE the pinned prompt even DURING a turn — no
848
+ # out-of-band $stdout race to smear the stream (the old D1/D3/D4 cause).
849
+ last_int_at = nil
850
+ in_trap = false
851
+
852
+ prev = Signal.trap("INT") do
853
+ # Guard against trap re-entrancy: a burst of signals must not stack.
854
+ unless in_trap
855
+ in_trap = true
856
+ begin
857
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
858
+ raise Interrupt if last_int_at && (now - last_int_at) <= DOUBLE_TAP_SECONDS
859
+
860
+ # Second tap in the window: raise to the main thread so the
861
+ # REPL unwinds and exits — a real Ctrl+C now quits.
862
+
863
+ last_int_at = now
864
+ runner.cancel!
865
+ # The runner commits the standardized dim "⎿ interrupted" marker
866
+ # once it unwinds the cancelled turn; here we only add the
867
+ # actionable double-tap hint so the two don't restate the same
868
+ # "interrupted" wording (L10). Single ASCII write —
869
+ # async-signal-safe enough for a trap.
870
+ $stderr.write("\n(press Ctrl+C again to exit)\n")
871
+ ensure
872
+ in_trap = false
873
+ end
874
+ end
875
+ end
876
+
877
+ # Stale-flag guard (#111): a quiet suppression armed by a prior turn
878
+ # that completed before observing its cancel must not swallow THIS
879
+ # turn's real `⎿ interrupted` marker.
880
+ ui.suppress_interrupt_marker(value: false) if ui.respond_to?(:suppress_interrupt_marker)
881
+
882
+ composer, real_stdout = start_composer(input_queue, runner)
883
+
884
+ # Mark the composer "in a turn" for the WHOLE turn — covering the THINKING
885
+ # phase AND the content stream — so a "queued ▸" type-ahead echo submitted
886
+ # before the first content token is deferred too, not stranded above the
887
+ # thought line and the answer (D7e). Cleared (and the deferred echoes
888
+ # flushed, after the footer) in the ensure below.
889
+ composer.begin_turn if composer.respond_to?(:begin_turn)
890
+
891
+ # If this turn's prompt came off the input queue (interrupt-by-default
892
+ # Enter, Alt+Enter, or "/queued" during the previous turn), commit it now
893
+ # as a NORMAL "<prompt><line>" message above the input — the same echo an
894
+ # idle submit gets — and remove its "⏳ queued:" indicator so it visibly
895
+ # MOVES from the above-input pending row to a transcript message at send
896
+ # time. An idle-submitted prompt already echoed at submit, so it isn't
897
+ # marked and is skipped here (no double echo).
898
+ commit_queued_prompt(composer)
899
+
900
+ # Open the TURN-SCOPED status row (the "Ruby facet" ticker): one engine
901
+ # thread for the whole turn — model waits, tools, inter-tool gaps AND
902
+ # the post-turn inline jobs all just swap its label. Closed in the
903
+ # ensure below (turn end / error / interrupt), so the post-footer
904
+ # polishing phase stays animated instead of freezing the UI.
905
+ ui.turn_started if ui.respond_to?(:turn_started)
906
+
907
+ # Pass the SAME queue the composer pushes into through to the agent loop:
908
+ # the loop drains it at each iteration boundary (Phase-2 mid-turn
909
+ # steering). Anything still queued in the gap after the turn ends falls
910
+ # back to #next_input for the NEXT turn (the MVP behaviour). nil ⇒ no
911
+ # injection (piped/-q input has no composer anyway).
912
+ run_kwargs = { image_paths: image_paths, input_queue: input_queue }
913
+ # Only thread the paste expansions when a placeholder was actually
914
+ # collected, so a normal turn's runner.run signature is unchanged.
915
+ run_kwargs[:paste_expansions] = paste_expansions unless paste_expansions.empty?
916
+ runner.run(prompt, **run_kwargs)
917
+ rescue Interrupt
918
+ # Reached on the second tap (raised from the trap) or a stray INT that
919
+ # escaped the cooperative path. Cancel and re-raise so run_interactive's
920
+ # loop breaks and the session ends cleanly.
921
+ runner.cancel!
922
+ ui.blank_line
923
+ ui.warning("turn cancelled")
924
+ raise
925
+ ensure
926
+ # End the turn BEFORE tearing the composer down: the runner has fully
927
+ # unwound here, so the turn-summary footer is already in scrollback. This
928
+ # clears the turn-active flag and flushes any deferred "queued ▸" echoes
929
+ # via the still-live composer's print_above, so they land AFTER the footer
930
+ # (answer → reveal → `↳ turn` → `queued ▸`). A no-content/aborted turn
931
+ # still flushes here, so a mid-turn type-ahead is never stranded.
932
+ # The status row stops FIRST — the post-turn jobs have drained by the
933
+ # time the runner returns, so the facet has already landed in the
934
+ # footer and the engine thread must not outlive the turn.
935
+ ui.turn_finished if ui.respond_to?(:turn_finished)
936
+ composer.end_turn if composer.respond_to?(:end_turn)
937
+ # Refresh the status bar (model + context saturation) now that the
938
+ # turn's messages are persisted — the "after each footer" boundary.
939
+ # The bar then stays correct for however long this composer remains
940
+ # pinned (post-turn inline jobs); the next idle composer recomputes it
941
+ # at build time anyway.
942
+ composer.set_status(build_status_line(runner)) if composer.respond_to?(:set_status)
943
+ stop_composer(composer, real_stdout)
944
+ Signal.trap("INT", prev) if prev
945
+ end
946
+
947
+ # The status-bar line for the CURRENT session (see UI::StatusBar):
948
+ # mode (+ branch/skill when set) · resolved model id · context
949
+ # saturation. Saturation prefers the REAL
950
+ # usage the provider reported for the session's last response (the
951
+ # input_tokens the agent loop records in the assistant message metadata
952
+ # — the whole assembled prompt incl. the system prompt) and falls back
953
+ # to the SAME estimate the compaction logic runs on —
954
+ # Context::TokenBudget#estimate_tokens (chars/4) over the stored
955
+ # messages. The window comes from `model.context_length` /
956
+ # `context.max_tokens` (TokenBudget's default otherwise), so the
957
+ # percentage tracks the compaction thresholds. nil (no bar) when
958
+ # disabled via display.statusbar or on any failure: a cosmetic line
959
+ # must never break the prompt.
960
+ def build_status_line(runner)
961
+ return nil unless runner && Rubino.configuration.display_statusbar?
962
+
963
+ session = runner.session
964
+ budget = Context::TokenBudget.new(model_id: session[:model], config: Rubino.configuration)
965
+ messages = ::Rubino::Session::Store.new.for_session(session[:id])
966
+ UI::StatusBar.render(
967
+ chips: { mode: Rubino::Modes.current, branch: @branch_short_id,
968
+ skill: Rubino::ActiveSkill.current },
969
+ model: session[:model] || model_name,
970
+ tokens: context_tokens(messages, budget),
971
+ window: budget.available_tokens,
972
+ pastel: pastel
973
+ )
974
+ rescue StandardError
975
+ nil
976
+ end
977
+
978
+ # Estimated tokens in the session's context: the last recorded REAL
979
+ # context size (input + output of the newest assistant response that
980
+ # carries usage) when available, else TokenBudget's chars/4 estimate.
981
+ def context_tokens(messages, budget)
982
+ last = messages.reverse_each.find { |m| m.metadata&.dig(:input_tokens).to_i.positive? }
983
+ return last.metadata[:input_tokens].to_i + last.token_count.to_i if last
984
+
985
+ budget.estimate_tokens(messages.map { |m| { content: m.content } })
986
+ end
987
+
988
+ # Commits the just-dequeued prompt as a normal "<prompt><line>" transcript
989
+ # message and removes its "⏳ queued:" indicator. Each line the previous
990
+ # turn parked (set in #next_input as @input_from_queue) is echoed in the
991
+ # clean "❯ " prompt, so a queued/interrupt-sent message reads back exactly
992
+ # like an idle submit. No-op when the prompt was an idle submit (already
993
+ # echoed) or there's no composer (piped / -q). Clears the marker after.
994
+ def commit_queued_prompt(composer)
995
+ lines = @input_from_queue
996
+ @input_from_queue = nil
997
+ return unless lines && composer
998
+
999
+ lines.each do |line|
1000
+ # Drop the live "⏳ queued:" row first (explicit-queue items), then
1001
+ # commit the normal echo above the input.
1002
+ composer.commit_queued(line) if composer.respond_to?(:commit_queued)
1003
+ composer.print_above("#{build_prompt}#{line}")
1004
+ end
1005
+ end
1006
+
1007
+ # The NON-TURN counterpart of #commit_queued_prompt (#192): a dequeued
1008
+ # line consumed by the dispatch loop WITHOUT running a model turn (a slash
1009
+ # command, a `!` shell escape, a `? ` probe, an image command) never
1010
+ # reaches #run_turn, so its "⏳ queued:" indicator would linger above the
1011
+ # composer across later prompts. Commit it here instead: drop the row from
1012
+ # the shared pending stack (the next composer renders from it) and echo
1013
+ # the line as the normal "<prompt><line>" message — no composer is live
1014
+ # between turns, so the echo goes straight to scrollback. No-op for an
1015
+ # idle submit (not flagged in @input_from_queue).
1016
+ def commit_queued_dispatch
1017
+ lines = @input_from_queue
1018
+ @input_from_queue = nil
1019
+ return unless lines
1020
+
1021
+ lines.each do |line|
1022
+ idx = pending_queued.index(line)
1023
+ pending_queued.delete_at(idx) if idx
1024
+ $stdout.puts("#{build_prompt}#{line}")
1025
+ end
1026
+ end
1027
+
1028
+ # Starts the bottom-pinned composer for the duration of a turn and swaps
1029
+ # $stdout for a proxy that routes all turn output through it.
1030
+ #
1031
+ # Returns [composer, real_stdout]. Both are nil unless steering is wired
1032
+ # AND both ends are real TTYs (UI::BottomComposer.active?) — for piped /
1033
+ # `-q` / server input there is nothing to read raw and we must not touch
1034
+ # terminal modes or swap $stdout, so this is a no-op there and the plain
1035
+ # path runs exactly as before.
1036
+ #
1037
+ # Terminal mode: the composer reader runs inside +$stdin.raw(intr: true)+
1038
+ # so each keystroke arrives unbuffered while +intr: true+ keeps the ISIG
1039
+ # flag on — Ctrl+C still generates SIGINT and reaches the double-tap trap
1040
+ # installed above (we never read or swallow \x03). The block form of #raw
1041
+ # restores the prior termios; #stop additionally forces cooked mode.
1042
+ #
1043
+ # The composer only appends to the thread-safe InputQueue; it never mutates
1044
+ # the runner or the agent loop, so it cannot race the turn own work — the
1045
+ # parked text is consumed by the loop at a safe iteration boundary (atomic
1046
+ # #drain), or by #next_input between turns for anything typed in the gap.
1047
+ def start_composer(input_queue, runner)
1048
+ return [nil, nil] unless input_queue && UI::BottomComposer.active?
1049
+
1050
+ # The mode/branch/skill context rides the STATUS BAR (build_status_line);
1051
+ # the prompt itself is the constant clean "❯ " behind the red rail.
1052
+ # `runner` is threaded in (not captured from an enclosing scope) so the
1053
+ # interrupt lambda resolves it — it is a parameter of #run_turn, not in
1054
+ # scope here, and there is no @runner ivar, so capturing it implicitly
1055
+ # raised NameError the instant an Enter-during-turn fired (BH-1).
1056
+ # Same completion + history wiring as the idle composer: the prompt is
1057
+ # pinned and editable for the WHOLE turn — including the post-turn
1058
+ # window where inline jobs (memory auto-extract, skill distill) spend
1059
+ # aux-LLM seconds after the `↳ turn` footer — so `/` and `@` dropdowns
1060
+ # and ↑↓ history work whenever the prompt is visible (#169).
1061
+ composer = UI::BottomComposer.new(input_queue: input_queue, prompt: build_prompt,
1062
+ rail: composer_rail,
1063
+ on_ctrl_o: ctrl_o_handler,
1064
+ on_mode_cycle: mode_cycle_handler(runner),
1065
+ on_interrupt: interrupt_handler(runner),
1066
+ completion_source: @completion_source,
1067
+ history: @input_history,
1068
+ pending_queued: pending_queued,
1069
+ status_line: build_status_line(runner),
1070
+ max_input_rows: Rubino.configuration.display_input_max_rows,
1071
+ paste_store: paste_store)
1072
+ composer.start
1073
+ real_stdout = $stdout
1074
+ # Force the lazily-built logger to bind to the REAL $stdout NOW, before
1075
+ # the swap — otherwise the first log call during the turn would build a
1076
+ # Logger against the proxy and route diagnostic lines into the chat (and,
1077
+ # after the turn, into a dead proxy). The logger stays on the real IO.
1078
+ Rubino.logger
1079
+ $stdout = UI::StdoutProxy.new(composer)
1080
+ [composer, real_stdout]
1081
+ rescue StandardError
1082
+ # Setup failed — fall back to the plain path so the turn still runs
1083
+ # (no raw, no proxy).
1084
+ composer&.stop
1085
+ $stdout = real_stdout if real_stdout
1086
+ [nil, nil]
1087
+ end
1088
+
1089
+ # The composer's Enter-during-turn hook: cancel the runner so the just-
1090
+ # submitted line runs as the next turn. +quiet+ marks a slash-command
1091
+ # submit at an idle-LOOKING moment — nothing visibly streaming, only the
1092
+ # live cards animating (#111) — so the UI is told to swallow the
1093
+ # upcoming `⎿ interrupted` marker instead of stranding it above the
1094
+ # command's own output.
1095
+ def interrupt_handler(runner)
1096
+ lambda { |quiet = false|
1097
+ ui = Rubino.ui
1098
+ ui.suppress_interrupt_marker if quiet && ui.respond_to?(:suppress_interrupt_marker)
1099
+ runner.cancel!
1100
+ }
1101
+ end
1102
+
1103
+ # Tears down the composer: restores the real $stdout, flushes any held
1104
+ # partial line into scrollback, stops the reader and restores cooked mode.
1105
+ # Safe to call with nils (no composer was started).
1106
+ def stop_composer(composer, real_stdout)
1107
+ proxy = $stdout
1108
+ $stdout = real_stdout if real_stdout
1109
+ proxy.finish if proxy.respond_to?(:finish)
1110
+ # Preserve an un-submitted draft (text typed during the turn with no
1111
+ # Enter) before tearing the composer down; #next_input pre-fills the next
1112
+ # prompt with it. A submitted line clears the buffer, so this only ever
1113
+ # carries genuinely-pending input. An empty buffer leaves any prior draft
1114
+ # untouched so it survives queued steering turns in between.
1115
+ if composer
1116
+ draft = composer.buffer.to_s
1117
+ @pending_draft = draft unless draft.strip.empty?
1118
+ end
1119
+ composer&.stop
1120
+ rescue IOError, Errno::ENOTTY, Errno::EIO
1121
+ nil
1122
+ end
1123
+
1124
+ # The leading `? ` ephemeral-probe trigger. Returns the side-question text
1125
+ # (everything after the `? `) when the line is a probe, nil otherwise. A
1126
+ # bare `?` or `?` with no following space is NOT a probe (so a real
1127
+ # message can start with `?` by typing it without the trailing space, or
1128
+ # by leading with a space per the escape rule in the UX doc).
1129
+ def probe_question(input)
1130
+ return nil unless input.start_with?("? ")
1131
+
1132
+ q = input[2..].to_s.strip
1133
+ q.empty? ? nil : q
1134
+ end
1135
+
1136
+ # Runs an ephemeral side-question against the live session and renders it
1137
+ # in the dim "probe (ephemeral · not saved)" aside, then DISCARDS it: the
1138
+ # Q&A never touches the session store, so the next real turn is unchanged.
1139
+ # The Q&A is stashed in @last_probe so a `/branch` right after can promote
1140
+ # it into the fork seed (the "actually, let's pursue this" move).
1141
+ def run_probe(runner, question, ui)
1142
+ # The probe is a synchronous side-inference with nothing streaming, so
1143
+ # the wait used to look frozen (#58): show the SAME thinking row a
1144
+ # normal turn gets, cleared before the aside (or failure) renders. TTY
1145
+ # only — never an indicator into a pipe.
1146
+ probe_thinking_started(ui)
1147
+ result = Interaction::Probe.new(
1148
+ session: runner.session,
1149
+ model_override: model_name,
1150
+ provider_override: opt(:provider)
1151
+ ).ask(question)
1152
+ probe_thinking_finished(ui)
1153
+ ui.probe_aside(result.answer)
1154
+ @last_probe = result
1155
+ rescue StandardError => e
1156
+ probe_thinking_finished(ui)
1157
+ # A probe is a throwaway aside — a failure must never break the REPL.
1158
+ ui.warning("probe failed: #{e.message}")
1159
+ @last_probe = nil
1160
+ end
1161
+
1162
+ # Forks the current session at this point into a NEW saved session and
1163
+ # returns a runner switched into it (the REPL replaces its runner with
1164
+ # this). The original session is left untouched.
1165
+ #
1166
+ # Reuse: Session::Repository#create(parent_session_id:) sets the lineage
1167
+ # column, and Session::Store#copy_into seeds the child with the parent's
1168
+ # message history so far — the same context a resume would replay. When
1169
+ # the immediately-preceding interaction was a probe (@last_probe set), its
1170
+ # Q&A is appended to the seed too, so an aside that "never happened" in the
1171
+ # original becomes the branch's starting point.
1172
+ def branch_runner(ui, parent_runner, title)
1173
+ parent = parent_runner.session
1174
+ store = ::Rubino::Session::Store.new
1175
+ # Persist the parent if it was a lazily-built, never-saved session, so a
1176
+ # branch from a brand-new chat still inherits whatever is there and the
1177
+ # parent_session_id points at a real row.
1178
+ Session::Repository.new.persist!(parent) if parent[:persisted] == false
1179
+
1180
+ child = Session::Repository.new.create(
1181
+ source: "cli",
1182
+ model: parent[:model],
1183
+ provider: parent[:provider],
1184
+ title: title,
1185
+ parent_session_id: parent[:id]
1186
+ )
1187
+
1188
+ store.copy_into(child[:id], store.for_session(parent[:id]))
1189
+ included_probe = seed_probe_into!(store, child[:id])
1190
+ # copy_into/seed write message rows but don't touch the session's cached
1191
+ # message_count, so sync it once here — otherwise /sessions shows the
1192
+ # inherited branch as "0 msgs" even though its transcript is populated.
1193
+ Session::Repository.new.update(child[:id], message_count: store.count(child[:id]))
1194
+
1195
+ ui.branch_confirmation(
1196
+ new_id: child[:id],
1197
+ parent_id: parent[:id],
1198
+ title: title,
1199
+ included_probe: included_probe
1200
+ )
1201
+
1202
+ @branch_short_id = child[:id][0..3]
1203
+ @last_probe = nil
1204
+ resume_runner(ui, child[:id])
1205
+ end
1206
+
1207
+ # Appends the immediately-preceding probe's Q&A to the branch seed when one
1208
+ # is present (the user is promoting the aside). Returns true if a probe was
1209
+ # folded in, false otherwise.
1210
+ def seed_probe_into!(store, child_session_id) # rubocop:disable Naming/PredicateMethod -- a seeding mutator that reports what it did
1211
+ probe = @last_probe
1212
+ return false unless probe
1213
+
1214
+ store.create(session_id: child_session_id, role: "user", content: probe.question)
1215
+ store.create(session_id: child_session_id, role: "assistant", content: probe.answer)
1216
+ true
1217
+ end
1218
+
1219
+ # --- Esc-Esc rewind (edit-and-resend) -----------------------------------
1220
+ #
1221
+ # Double-Esc at the idle prompt walks back through the session's USER
1222
+ # messages: a picker (the same arrow-key machinery /sessions uses, Esc
1223
+ # cancels) lists them most recent first; picking one FORKS the session at
1224
+ # the point BEFORE that message (the /branch copy-truncated infra), parks
1225
+ # the fork's runner for the REPL to adopt, and pre-fills the composer
1226
+ # with the message text ready to edit — Enter sends it as the next turn
1227
+ # on the fork. The original session is never touched.
1228
+
1229
+ # Run the rewind flow. Returns the fork's runner on a pick, nil on
1230
+ # cancel / nothing to rewind to. Must run OFF the composer's reader
1231
+ # thread: ui.select suspends the composer (run_in_terminal), which joins
1232
+ # that thread.
1233
+ def handle_rewind(composer, runner, ui)
1234
+ messages = ::Rubino::Session::Store.new.for_session(runner.session[:id])
1235
+ user_idx = messages.each_index.select { |i| rewindable_message?(messages[i]) }
1236
+ if user_idx.empty?
1237
+ composer.announce("(no earlier message to rewind to)")
1238
+ return nil
1239
+ end
1240
+
1241
+ choices = user_idx.reverse.map { |i| [rewind_choice_label(messages[i]), i] }
1242
+ chosen = ui.select("Rewind to which message? (Esc to cancel)", choices)
1243
+ return nil if chosen.nil?
1244
+
1245
+ rewind_onto_fork(composer, runner, ui, messages, chosen,
1246
+ ordinal: user_idx.index(chosen) + 1)
1247
+ end
1248
+
1249
+ # Fork the session at the picked message and switch onto it: seed the
1250
+ # child with everything BEFORE the message (copy-truncated), adopt the
1251
+ # fork's runner + status bar, print the dim note, and pre-fill the
1252
+ # composer with the message text (multiline-safe) for edit-and-resend.
1253
+ def rewind_onto_fork(composer, runner, ui, messages, index, ordinal:)
1254
+ child = rewind_fork(runner, messages.first(index))
1255
+ # The rewind has its own "┄ rewound to message N — editing ┄" marker, so
1256
+ # suppress the generic "Resuming session: <id>…" plumbing line the runner
1257
+ # would otherwise emit on the fork switch (#220).
1258
+ new_runner = build_runner(session_id: child[:id], ui: ui, announce_session: false)
1259
+ @branch_short_id = child[:id][0..3]
1260
+ ui.note("rewound to message #{ordinal} — editing")
1261
+ composer.set_status(build_status_line(new_runner))
1262
+ composer.prefill(messages[index].content)
1263
+ new_runner
1264
+ end
1265
+
1266
+ # A row the rewind picker offers: a REAL typed user message — not a tool
1267
+ # result riding the user role, and not the `!` bang-shell injections
1268
+ # (<bash-input>/<bash-stdout> context glue is not something to resend).
1269
+ def rewindable_message?(msg)
1270
+ msg.role == "user" && msg.tool_call_id.nil? &&
1271
+ !msg.content.to_s.start_with?("<bash-")
1272
+ end
1273
+
1274
+ # One picker row: `N ago · <first 60 chars>` — recency + a flattened
1275
+ # snippet, enough to recognize the turn at a glance.
1276
+ def rewind_choice_label(msg)
1277
+ snippet = msg.content.to_s.gsub(/\s+/, " ").strip
1278
+ snippet = "#{snippet[0, REWIND_SNIPPET_CHARS]}…" if snippet.length > REWIND_SNIPPET_CHARS
1279
+ age = message_age(msg)
1280
+ age ? "#{age} · #{snippet}" : snippet
1281
+ end
1282
+
1283
+ # "5m ago" for a message row (same humanization as the /sessions picker);
1284
+ # nil when the timestamp is unparseable — the row renders without it.
1285
+ def message_age(msg)
1286
+ created = msg.created_at
1287
+ created = Time.parse(created.to_s) unless created.is_a?(Time)
1288
+ "#{Rubino::Util::Duration.human_duration(Time.now - created)} ago"
1289
+ rescue StandardError
1290
+ nil
1291
+ end
1292
+
1293
+ # The copy-truncated fork (the /branch infra, cut at the rewind point):
1294
+ # a child session with lineage set, seeded with +seed_messages+ — every
1295
+ # message BEFORE the picked one — leaving the original untouched.
1296
+ def rewind_fork(runner, seed_messages)
1297
+ parent = runner.session
1298
+ repo = Session::Repository.new
1299
+ # Persist a lazily-built, never-saved parent first, exactly as /branch
1300
+ # does, so parent_session_id points at a real row.
1301
+ repo.persist!(parent) if parent[:persisted] == false
1302
+
1303
+ child = repo.create(
1304
+ source: "cli",
1305
+ model: parent[:model],
1306
+ provider: parent[:provider],
1307
+ title: nil,
1308
+ parent_session_id: parent[:id]
1309
+ )
1310
+ store = ::Rubino::Session::Store.new
1311
+ store.copy_into(child[:id], seed_messages)
1312
+ # copy_into writes message rows but not the cached message_count —
1313
+ # sync it once, same as /branch (#/sessions would show "0 msgs").
1314
+ repo.update(child[:id], message_count: store.count(child[:id]))
1315
+ child
1316
+ end
1317
+
1318
+ # The Ctrl+O callback for the composer: reveal the last retained reasoning
1319
+ # aside via the UI adapter (the CLI keeps the buffer). The reveal commits
1320
+ # through the composer's serialized print_above, so it lands cleanly above
1321
+ # the prompt idle OR mid-turn. nil when the adapter can't reveal, so the
1322
+ # composer treats Ctrl+O as a no-op.
1323
+ def ctrl_o_handler
1324
+ ui = Rubino.ui
1325
+ return nil unless ui.respond_to?(:reveal_last_reasoning)
1326
+
1327
+ -> { ui.reveal_last_reasoning }
1328
+ end
1329
+
1330
+ # The Shift+Tab callback for the composer: cycle the mode to the next in
1331
+ # Modes::ALL (default→plan→yolo→default), PERSIST it via Modes.set, show
1332
+ # the transition toast, and RETURN the freshly-built STATUS-BAR line so
1333
+ # the composer updates the mode token LIVE (the mode lives in the status
1334
+ # bar now, not in a prompt chip). +runner+ feeds the bar's model/context
1335
+ # numbers. The composer holds no mode logic — it just adopts the
1336
+ # returned status line.
1337
+ def mode_cycle_handler(runner)
1338
+ -> { cycle_mode(runner) }
1339
+ end
1340
+
1341
+ # Shift+Tab: cycle the mode, show a SINGLE TRANSIENT confirmation banner,
1342
+ # and RETURN the freshly-built status-bar line so the composer redraws the
1343
+ # mode token LIVE (fixes the stale-chip D7). The persistent indicator is
1344
+ # the STATUS BAR's leading mode token; the banner is a one-shot toast
1345
+ # rendered in the composer's live region via #announce — redrawn in place,
1346
+ # cleared on the next keystroke, NEVER committed to scrollback. So cycling
1347
+ # N times leaves ZERO stacked banner lines (D3) and a mid-stream Shift+Tab
1348
+ # can't wedge a banner between answer chunks (D2). With no composer
1349
+ # (cooked fallback) it falls back to a plain dim line.
1350
+ #
1351
+ # Entering YOLO from the cycle is gated behind a second press (#152):
1352
+ # the press that lands on yolo only ARMS it and shows a confirm toast;
1353
+ # blind mashing past plan can no longer silently drop the approval gates
1354
+ # of the session AND its running background children. An explicit
1355
+ # `/mode yolo` stays direct.
1356
+ def cycle_mode(runner = nil)
1357
+ previous = Rubino::Modes.current
1358
+ idx = Rubino::Modes::ALL.index(previous) || 0
1359
+ nxt = Rubino::Modes::ALL[(idx + 1) % Rubino::Modes::ALL.length]
1360
+ return announce_yolo_confirm if nxt == Rubino::Modes::YOLO && !yolo_cycle_confirmed?
1361
+
1362
+ @yolo_armed_at = nil
1363
+ Rubino::Modes.set(nxt)
1364
+ # Same `<old> → <new>` arrow grammar as the /mode footer (#78), plus
1365
+ # the description and the cycle hint only this transient toast carries.
1366
+ show_mode_footer("┄ mode #{previous} → #{nxt} — #{Rubino::Modes.description(nxt)}, shift+tab to cycle ┄")
1367
+ build_status_line(runner)
1368
+ end
1369
+
1370
+ # True when THIS Shift+Tab press is the deliberate second press that
1371
+ # confirms entering yolo. Anything else (first press, mash, stale arm)
1372
+ # (re-)arms and returns false.
1373
+ def yolo_cycle_confirmed?
1374
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1375
+ elapsed = @yolo_armed_at ? now - @yolo_armed_at : nil
1376
+ return true if elapsed&.between?(YOLO_CONFIRM_MIN_SECONDS, YOLO_CONFIRM_WINDOW_SECONDS)
1377
+
1378
+ @yolo_armed_at = now
1379
+ false
1380
+ end
1381
+
1382
+ # The arm toast: says what yolo will do — including to RUNNING background
1383
+ # children, whose gates drop the moment the mode flips — and how to
1384
+ # confirm. Returns nil (the mode did not change ⇒ no status-bar update).
1385
+ def announce_yolo_confirm
1386
+ live = Tools::BackgroundTasks.instance.running.size
1387
+ children = live.positive? ? " — #{live} running subagent(s) will run gated actions unprompted" : ""
1388
+ show_mode_footer("┄ yolo skips ALL approvals#{children} — press shift+tab again to confirm ┄")
1389
+ nil
1390
+ end
1391
+
1392
+ # Routes a transient mode footer through the live composer's #announce
1393
+ # (never committed to scrollback, D2/D3) or, with no composer (cooked
1394
+ # fallback), prints a plain dim line.
1395
+ def show_mode_footer(text)
1396
+ footer = pastel.dim(text)
1397
+ composer = UI::BottomComposer.current
1398
+ if composer
1399
+ composer.announce(footer)
1400
+ else
1401
+ $stdout.print "\n#{footer}\n"
1402
+ $stdout.flush
1403
+ end
1404
+ end
1405
+
1406
+ # The clean Rail-rubino prompt: a bare "❯ " caret. The mode/branch/skill
1407
+ # chip that used to lead it lives in the STATUS BAR now (see
1408
+ # #build_status_line / UI::StatusBar) — the composer prepends the red
1409
+ # rail itself (#composer_rail), so committed echoes built from this
1410
+ # ("❯ <line>") stay rail-free in scrollback.
1411
+ def build_prompt
1412
+ "#{PROMPT_CARET} "
1413
+ end
1414
+
1415
+ # The one-column brand rail (the red ▍ glyph) the composer draws as
1416
+ # the first column of EVERY input row — first row and continuations.
1417
+ # Pastel auto-disables color off a TTY, and the composer itself only
1418
+ # runs on a real TTY, so the rail never reaches piped output.
1419
+ def composer_rail
1420
+ pastel.red(PROMPT_RAIL)
1421
+ end
1422
+
1423
+ def pastel
1424
+ @pastel ||= Pastel.new
1425
+ end
1426
+
1427
+ def collapse_home(path)
1428
+ home = Dir.home
1429
+ path.start_with?(home) ? path.sub(home, "~") : path
1430
+ rescue ArgumentError
1431
+ path
1432
+ end
1433
+
1434
+ # Best-effort git status. Returns nil outside a checkout. Shells out
1435
+ # because we're already paying a readline-roundtrip on every prompt —
1436
+ # 3 git commands at ~5ms each is invisible against that.
1437
+ def git_context
1438
+ return nil unless system("git rev-parse --is-inside-work-tree > /dev/null 2>&1")
1439
+
1440
+ branch = `git branch --show-current 2>/dev/null`.strip
1441
+ sha = `git rev-parse --short HEAD 2>/dev/null`.strip
1442
+ dirty = !`git status --porcelain 2>/dev/null`.strip.empty?
1443
+ return nil if branch.empty? && sha.empty?
1444
+
1445
+ { branch: branch.empty? ? "(detached)" : branch, sha: sha, dirty: dirty }
1446
+ end
1447
+
1448
+ # --- Helpers ---
1449
+
1450
+ def opt(key)
1451
+ @options[key] || @options[key.to_s]
1452
+ end
1453
+
1454
+ # Seeds extra workspace roots from --add-dir and runs the folder-trust
1455
+ # gate for the primary root and each added dir, BEFORE any turn assembles
1456
+ # a system prompt (so an untrusted dir's AGENTS.md/skills are withheld).
1457
+ # +interactive+ false (one-shot/-q) skips the prompt entirely.
1458
+ def setup_workspace_and_trust!(ui, interactive:)
1459
+ gate = TrustGate.new(ui: ui, interactive: interactive, ignore_rules: opt(:ignore_rules) || false)
1460
+
1461
+ # Primary root first — the dir rubino was launched in.
1462
+ gate.ensure_trust(Rubino::Workspace.primary_root)
1463
+
1464
+ Array(opt(:add_dir)).each do |dir|
1465
+ real = Rubino::Workspace.add(dir)
1466
+ ui.status("added workspace #{collapse_home(real)}") if ui.respond_to?(:status)
1467
+ gate.ensure_trust(real)
1468
+ rescue ArgumentError => e
1469
+ ui.error("--add-dir #{dir}: #{e.message}") if ui.respond_to?(:error)
1470
+ end
1471
+ end
1472
+
1473
+ def model_name
1474
+ opt(:model) || opt(:m) || Rubino.configuration.model_default
1475
+ end
1476
+
1477
+ def model_override_given?
1478
+ !!(opt(:model) || opt(:m))
1479
+ end
1480
+
1481
+ # Echoes the effective model in one-shot mode and warns on an unknown id
1482
+ # (#142). The warning + echo go to stderr so the answer on stdout stays
1483
+ # clean for piping. Only fires for an explicit `-m`/`--model` override so
1484
+ # the default-model happy path is unchanged.
1485
+ def announce_resolved_model
1486
+ return unless model_override_given?
1487
+
1488
+ warn "model: #{model_name}"
1489
+ warn_unknown_model
1490
+ end
1491
+
1492
+ # When the resolved model id isn't in the known catalog, print a clear
1493
+ # stderr warning — then PROCEED (assume-exists providers like MiniMax pass
1494
+ # arbitrary ids through deliberately), so a typo no longer becomes a silent
1495
+ # wrong-model run (#142).
1496
+ def warn_unknown_model
1497
+ id = model_name
1498
+ return if id.nil? || id.to_s.empty?
1499
+ return if model_known?(id)
1500
+
1501
+ warn "rubino: warning: model '#{id}' is not in the known model catalog " \
1502
+ "(accepted unverified; a typo here will hit the provider as-is)."
1503
+ end
1504
+
1505
+ # True when the model id resolves in ruby_llm's registry. A fake/* id (the
1506
+ # dev FakeProvider) is always treated as known so it never triggers the
1507
+ # warning. Any registry hiccup is treated as "known" so we never block on a
1508
+ # cosmetic check.
1509
+ def model_known?(id)
1510
+ return true if id.to_s.start_with?("fake/") || opt(:provider).to_s == "fake"
1511
+
1512
+ !RubyLLM.models.find(id).nil?
1513
+ rescue RubyLLM::ModelNotFoundError
1514
+ false
1515
+ rescue StandardError
1516
+ # A registry-load hiccup must not produce a false "unknown" warning;
1517
+ # treat it as known and let the provider be the source of truth.
1518
+ true
1519
+ end
1520
+
1521
+ # The `--max-turns N` flag, threaded into the runner so it actually caps
1522
+ # per-turn tool iterations (#141). Thor delivers a numeric as a Float;
1523
+ # the IterationBudget coerces/validates it (0/blank ⇒ use config default).
1524
+ def max_turns_override
1525
+ opt(:max_turns) || opt(:"max-turns")
1526
+ end
1527
+
1528
+ # Builds an Agent::Runner with this invocation's shared flag overrides —
1529
+ # only the session and UI vary per call site (one-shot, interactive boot,
1530
+ # /sessions resume, /new).
1531
+ def build_runner(session_id:, ui:, announce_session: true)
1532
+ Agent::Runner.new(
1533
+ session_id: session_id,
1534
+ model_override: model_name,
1535
+ provider_override: opt(:provider),
1536
+ max_turns: max_turns_override,
1537
+ ignore_rules: opt(:ignore_rules) || false,
1538
+ ui: ui,
1539
+ announce_session: announce_session
1540
+ )
1541
+ end
1542
+
1543
+ # Rebuilds the runner on a chosen session (the /sessions in-chat resume)
1544
+ # and replays its history so the transcript matches what was there before.
1545
+ def resume_runner(ui, session_id)
1546
+ runner = build_runner(session_id: session_id, ui: ui)
1547
+ session_resolver.print_session_history(ui, runner.session[:id])
1548
+ runner
1549
+ end
1550
+
1551
+ # Builds a runner on a brand-new session (the in-chat `/new`), without
1552
+ # passing any session_id so the runner creates a fresh one.
1553
+ def fresh_runner(ui)
1554
+ build_runner(session_id: nil, ui: ui)
1555
+ end
1556
+
1557
+ # `--yolo` is the CLI flag form of `/mode yolo`. We route both through
1558
+ # Rubino::Modes so the status bar's mode token, the API event, and the
1559
+ # ApprovalPolicy short-circuit all see a single source of truth.
1560
+ def apply_yolo!
1561
+ Rubino::Modes.set(:yolo)
1562
+ end
1563
+
1564
+ def ensure_setup!
1565
+ ensure_database_ready!
1566
+
1567
+ # Same opt-in gate as ServerCommand: fake provider is dev-only and
1568
+ # must not be reachable without RUBINO_ALLOW_FAKE=1.
1569
+ if Rubino.configuration.model_provider.to_s == "fake" &&
1570
+ ENV["RUBINO_ALLOW_FAKE"] != "1"
1571
+ warn "fake provider is dev-only — set RUBINO_ALLOW_FAKE=1 to opt in."
1572
+ exit(1)
1573
+ end
1574
+
1575
+ # Without this the tool registry stays empty, Lifecycle#load_tools
1576
+ # returns [], no `tools: [...]` is sent on the wire, and the model
1577
+ # has no choice but to roleplay bash in markdown. Symptom verified
1578
+ # via RUBYLLM_DEBUG=1 — request body was missing `tools` entirely.
1579
+ # Gate on a missing CORE tool, not on emptiness: a partially-populated
1580
+ # registry (e.g. only "shell" left behind) must still get the defaults
1581
+ # re-registered — #register is idempotent by name and never touches
1582
+ # MCP-prefixed wrappers.
1583
+ Rubino::Tools::Registry.register_defaults! unless Rubino::Tools::Registry.find("write")
1584
+
1585
+ # MCP is experimental and opt-in: a configured `mcp.servers` block
1586
+ # connects the servers and registers their prefixed tools alongside
1587
+ # the built-ins (#91). Best-effort — boot! warns and returns nil on
1588
+ # failure, it never blocks chat.
1589
+ Rubino::MCP.boot!
1590
+
1591
+ # Instantiate the shared agent registry at boot so the `task` tool can
1592
+ # resolve subagents (explore/general) in chat — same delegation flow as
1593
+ # the API path. Memoized on Rubino.agent_registry.
1594
+ Rubino.agent_registry
1595
+ end
1596
+
1597
+ # First-run credential gate (#93). Before any model call, check the
1598
+ # resolved provider actually has a usable key. If it does, do nothing —
1599
+ # an already-configured user is unaffected. If it doesn't:
1600
+ # • interactive TTY → run the onboarding wizard so the user picks a
1601
+ # provider/model and pastes a key; bail out if they decline.
1602
+ # • non-interactive (-q / piped / no TTY) → print the clear, actionable
1603
+ # guidance to stderr and exit non-zero, instead of dropping into an
1604
+ # ~80s silent-retry storm that exits 0 empty.
1605
+ # An explicit --model/--provider override or RUBINO_ALLOW_FAKE bypasses
1606
+ # this gate (the user is steering deliberately).
1607
+ def ensure_model_configured!
1608
+ # An explicit --model/--provider means the user is steering deliberately
1609
+ # (e.g. fake provider, a local model, a per-invocation override): skip the
1610
+ # config-based preflight and let the runtime classifier fail fast on a
1611
+ # real missing credential. The preflight only guards the DEFAULT path.
1612
+ return if opt(:model) || opt(:m) || opt(:provider)
1613
+ return if LLM::CredentialCheck.usable?
1614
+
1615
+ if interactive_setup_possible?
1616
+ ok = OnboardingWizard.new(ui: Rubino.ui).run
1617
+ # Re-check: the wizard wrote config/.env in this process. If the user
1618
+ # skipped or it still isn't usable, fall through to the guidance/exit.
1619
+ return if ok && LLM::CredentialCheck.usable?
1620
+ end
1621
+
1622
+ warn LLM::CredentialCheck.missing_key_message
1623
+ exit(1)
1624
+ end
1625
+
1626
+ # Onboarding is only meaningful when we can actually prompt the user: both
1627
+ # ends a real TTY, and not a one-shot/scripted invocation.
1628
+ def interactive_setup_possible?
1629
+ return false if opt(:query) || opt(:q)
1630
+
1631
+ $stdin.tty? && $stdout.tty?
1632
+ rescue StandardError
1633
+ false
1634
+ end
1635
+
1636
+ def exit_command?(input)
1637
+ %w[exit quit bye /exit /quit].include?(input.strip.downcase)
1638
+ end
1639
+
1640
+ # Background subagents die with the process (nothing is persisted), so a
1641
+ # /quit with live children must not be silent (#154): list them and
1642
+ # confirm, default No. Off a real terminal there is no one to ask — the
1643
+ # listed warning becomes the clear kill notice and the exit proceeds.
1644
+ def confirm_quit?(ui)
1645
+ live = Rubino::Tools::BackgroundTasks.instance.running
1646
+ return true if live.empty?
1647
+
1648
+ n = live.size
1649
+ ui.warning("#{n} background subagent#{"s" if n != 1} still running — quitting stops " \
1650
+ "#{n == 1 ? "it" : "them"} (partial side effects may remain):")
1651
+ live.each { |e| ui.info(" #{e.id} · #{e.subagent} · #{e.status}") }
1652
+ return true unless ui.respond_to?(:interactive_terminal?) && ui.interactive_terminal?
1653
+
1654
+ answer = ui.ask("quit anyway? [y/N] ")
1655
+ %w[y yes].include?(answer.to_s.strip.downcase)
1656
+ end
1657
+
1658
+ # First-run guard. A brand-new user who runs `chat` before `setup` used
1659
+ # to hit a raw `SQLite3::SQLException: no such table: sessions` stack
1660
+ # trace: `database.healthy?` only runs `SELECT 1`, which succeeds the
1661
+ # moment SQLite lazily creates an empty file — the schema is still
1662
+ # missing (F2). Detect the un-migrated DB and auto-initialize (create the
1663
+ # home dirs + run migrations); migrations are idempotent, so this is safe
1664
+ # to run every boot. Only fall back to a friendly "run setup" message if
1665
+ # the auto-init itself fails, never a Ruby backtrace.
1666
+ def ensure_database_ready!
1667
+ return if Rubino.ensure_database_ready!
1668
+
1669
+ warn "rubino isn't set up yet — run `rubino setup` first."
1670
+ exit(1)
1671
+ end
1672
+ end
1673
+ end
1674
+ end