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,1987 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "tty-table"
5
+ require "tty-spinner"
6
+ require "pastel"
7
+ require "securerandom"
8
+ require "unicode/display_width"
9
+
10
+ module Rubino
11
+ module UI
12
+ # Terminal-based UI adapter using TTY gems.
13
+ #
14
+ # All output goes to stdout via plain prints — no alt-screen, no
15
+ # mouse capture, no cursor positioning. Native terminal scroll, copy,
16
+ # and shell history all keep working because we never leave the
17
+ # main screen.
18
+ #
19
+ # Extends PrinterBase; uses compact append-only timeline rendering
20
+ # (no boxes, no per-element timestamps, no horizontal rules).
21
+ # Visual language:
22
+ # ● active tool or activity
23
+ # ✓ completed successfully
24
+ # ✗ failed
25
+ # ◆ approval required
26
+ # ┄ low-priority metadata
27
+ class CLI < PrinterBase
28
+ # Page size tty-prompt paginates a select menu at (its Paginator's
29
+ # DEFAULT_PAGE_SIZE) — the count of menu rows visible at once, used to wipe
30
+ # a cancelled picker's frame (#219).
31
+ PICKER_PAGE_SIZE = 6
32
+
33
+ # @param session_id [String] key for the session approval cache. One
34
+ # CLI process serves exactly one chat session, so a per-process id is
35
+ # the right granularity for "remember for this session" — the cache is
36
+ # in-memory/process-lifetime anyway. Injectable for tests.
37
+ # @param approval_cache [Run::SessionApprovalCache] shared cache so a
38
+ # prior "always" decision short-circuits the prompt, matching UI::API.
39
+ def initialize(session_id: nil, approval_cache: nil)
40
+ super()
41
+ @prompt = TTY::Prompt.new
42
+ @stream_type = nil
43
+ @stream_md = nil # StreamingMarkdown buffer, lazily built per content stream
44
+ @thinking_indicator = false
45
+ # Turn-scoped status row ("Ruby facet"): ONE ticker thread per turn —
46
+ # started when the turn (or a stand-alone wait like /probe) starts and
47
+ # stopped only at turn end / error / interrupt. Events swap its LABEL
48
+ # under @status_mutex instead of killing the thread, so inter-tool gaps
49
+ # and post-turn inline jobs keep an animated row instead of dead air.
50
+ # @thinking_started_at marks the start of the current reasoning phase so
51
+ # the collapse cue can report the elapsed seconds, and @reasoning_buffer
52
+ # accumulates the model's reasoning deltas (no longer raw-printed) for
53
+ # the collapse cue / full aside / ctrl-o.
54
+ @thinking_thread = nil
55
+ @status_mutex = Mutex.new
56
+ @status = nil
57
+ @turn_active = false
58
+ @turn_started_at = nil
59
+ @turn_tool_count = 0
60
+ @turn_tok_chars = 0
61
+ @thinking_started_at = nil
62
+ @reasoning_buffer = +""
63
+ # The last retained reasoning block (committed/collapsed), revealable via
64
+ # ctrl-o even after the answer has streamed. Reset per turn.
65
+ @last_reasoning = nil
66
+ @last_reasoning_seconds = nil
67
+ @activity_open = false
68
+ @activity_name = nil
69
+ # Rhythm tracker (P3): the kind of the last committed block — :tool
70
+ # (frames butt together), :gap (a trailing blank is already open, so
71
+ # the next separator is skipped), :answer, :other.
72
+ @last_block = :other
73
+ # Task ids whose FULL report the lifecycle block already rendered
74
+ # (#subagent_lifecycle): the injected completion notice for one of
75
+ # these drops its duplicated Result body (#elide_shown_reports).
76
+ @reported_subagent_ids = []
77
+ @session_id = session_id || SecureRandom.uuid
78
+ @approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance
79
+ end
80
+
81
+ # The attention notifier (terminal bell + optional command hook).
82
+ # Public so the background-task plumbing can ring it when a child
83
+ # parks on an approval (TaskTool#approval_handler_for).
84
+ def notifier
85
+ @notifier ||= Notifier.new
86
+ end
87
+
88
+ # Renders a table, degrading to a readable vertical card layout when the
89
+ # full grid would overflow a narrow terminal (#84). The card layout uses
90
+ # FULL field labels (no `Cre…`/`Sta…` truncation — each label sits alone
91
+ # with room to spare) and a rule between records so cards don't run
92
+ # together. Field order is the header order the caller chose, which the
93
+ # list callers now lead with the identifying fields (ID/Title/Created).
94
+ def table(headers:, rows:)
95
+ if grid_overflows?(headers, rows)
96
+ render_cards(headers, rows)
97
+ else
98
+ tbl = TTY::Table.new(header: headers, rows: rows)
99
+ # Pin the width explicitly: TTY::Table otherwise probes the terminal
100
+ # via ioctl, which blows up when $stdout is a StringIO (tests/pipes).
101
+ $stdout.puts tbl.render(:unicode, padding: [0, 1], width: terminal_cols, resize: false)
102
+ end
103
+ end
104
+
105
+ # True when the natural grid width (column maxima + unicode borders +
106
+ # padding) won't fit the terminal. Measured by display width so wide
107
+ # glyphs count as 2. Computed directly so we never have to render-then-
108
+ # measure (which would probe the terminal and crash on a StringIO).
109
+ def grid_overflows?(headers, rows)
110
+ col_widths = Array.new(headers.size, 0)
111
+ ([headers] + rows).each do |row|
112
+ row.each_with_index { |cell, i| col_widths[i] = [col_widths[i], display_width(cell.to_s)].max }
113
+ end
114
+ # Per column: 1 left border + 2 padding + content; plus 1 closing border.
115
+ natural = col_widths.sum { |w| w + 3 } + 1
116
+ natural > terminal_cols
117
+ end
118
+
119
+ # Vertical key/value cards: `Label value`, labels padded to a common
120
+ # width, a dim rule between records. No header truncation.
121
+ def render_cards(headers, rows)
122
+ label_w = headers.map { |h| display_width(h.to_s) }.max.to_i
123
+ rule = @pastel.dim("─" * [[terminal_cols, 1].max, 40].min)
124
+ rows.each_with_index do |row, i|
125
+ $stdout.puts rule if i.positive?
126
+ headers.each_with_index do |h, col|
127
+ label = h.to_s.ljust(label_w + (h.to_s.length - display_width(h.to_s)))
128
+ $stdout.puts "#{label} #{row[col]}"
129
+ end
130
+ end
131
+ end
132
+
133
+ # Terminal column count, headless-safe (falls back to 80).
134
+ def terminal_cols
135
+ cols = begin
136
+ IO.console&.winsize&.last
137
+ rescue StandardError
138
+ nil
139
+ end
140
+ cols&.positive? ? cols : 80
141
+ end
142
+
143
+ def display_width(str)
144
+ Unicode::DisplayWidth.of(str.to_s)
145
+ end
146
+
147
+ def ask(prompt)
148
+ # Off a real terminal (piped / non-interactive) there is no user who
149
+ # can answer, TTY::Prompt would leak raw cursor-control escapes into
150
+ # the stream (#106), and it would read whatever ambient stdin happens
151
+ # to hold (#107). Fail closed: no prompt, deterministic nil.
152
+ return nil unless interactive_terminal?
153
+
154
+ # A mid-turn prompt must own the real terminal: pause the bottom composer
155
+ # so TTY::Prompt reads the real $stdin and tty-screen probes the real
156
+ # $stdout (not the write-only StdoutProxy). No-op when no composer is
157
+ # active (between-turns / piped input).
158
+ BottomComposer.run_in_terminal { @prompt.ask(prompt) }
159
+ end
160
+
161
+ # True when both ends are a real interactive terminal — the shared gate
162
+ # for every interactive prompt/menu (#ask / #select): off a TTY they
163
+ # return nil instead of rendering ANSI into a pipe.
164
+ #
165
+ # While a bottom composer owns the screen, $stdout is the WRITE-ONLY
166
+ # StdoutProxy (tty? deliberately false) but the terminal itself is real —
167
+ # BottomComposer.active? gates composer creation on both ends being TTYs.
168
+ # Probing the swapped global would wrongly bail a picker opened from
169
+ # under the pinned prompt (the Esc-Esc rewind), so a live composer
170
+ # answers the question directly; run_in_terminal then restores the real
171
+ # IOs for the prompt's lifetime.
172
+ def interactive_terminal?
173
+ return true if BottomComposer.current
174
+
175
+ $stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty?
176
+ rescue StandardError
177
+ false
178
+ end
179
+
180
+ # Arrow-key single-select menu — the SAME TTY::Prompt component the tool
181
+ # approval menu uses (see #approval_choice), so /sessions resume reuses the
182
+ # existing picker rather than introducing a second menu system (#145).
183
+ # +choices+ is an array of [label, value] pairs. Returns the chosen value,
184
+ # or nil when there's no real terminal (so the caller keeps the
185
+ # non-interactive shortcut). Esc/Ctrl-C cancels and returns nil — Esc via
186
+ # the #cancellable_prompt keyescape binding (#73), Ctrl-C via tty-prompt's
187
+ # own InputInterrupt; both land in the rescue below.
188
+ def select(prompt, choices)
189
+ return nil if choices.nil? || choices.empty?
190
+ return nil unless interactive_terminal?
191
+
192
+ BottomComposer.run_in_terminal do
193
+ cancellable_prompt.select(prompt, cycle: false, filter: true) do |menu|
194
+ choices.each { |label, value| menu.choice label, value }
195
+ end
196
+ end
197
+ rescue TTY::Reader::InputInterrupt
198
+ # Esc aborts tty-prompt mid-render — the exception unwinds straight out of
199
+ # its draw loop, so the per-frame refresh that would have CLEARED the just
200
+ # drawn header + menu never runs. The frame is left committed to the
201
+ # scrollback (a dead "Resume which session? …" / "Rewind to which
202
+ # message? …" header + its first row), and repeated cancels stack corpses
203
+ # (#219). Erase the picker's frame so cancel restores the prompt cleanly —
204
+ # "nothing changed", as documented. The cursor is parked at the end of the
205
+ # last menu row, so we walk up over every drawn line and wipe to the end
206
+ # of the screen.
207
+ erase_picker_frame(choices.length)
208
+ nil
209
+ end
210
+
211
+ # Clears a cancelled picker's drawn frame: 1 header row + the visible menu
212
+ # rows (tty-prompt paginates at PICKER_PAGE_SIZE). Walks the cursor up to
213
+ # the header column-0 and erases everything below it, leaving the terminal
214
+ # exactly as it was before the picker opened.
215
+ def erase_picker_frame(choice_count)
216
+ rows = 1 + [choice_count, PICKER_PAGE_SIZE].min
217
+ $stdout.print(TTY::Cursor.column(1))
218
+ $stdout.print(TTY::Cursor.up(rows))
219
+ $stdout.print(TTY::Cursor.clear_screen_down)
220
+ end
221
+
222
+ # A DEDICATED TTY::Prompt for cancellable pickers, with Esc bound to the
223
+ # same InputInterrupt Ctrl-C raises (#73): tty-reader parses full escape
224
+ # sequences, so arrows (ESC [ A…) never trip :keyescape — only a lone Esc
225
+ # does. Deliberately separate from the shared @prompt so the approval
226
+ # menu's keymap is untouched (an Esc there must not become a deny).
227
+ def cancellable_prompt
228
+ @cancellable_prompt ||= TTY::Prompt.new.tap do |picker|
229
+ picker.on(:keyescape) { raise TTY::Reader::InputInterrupt }
230
+ end
231
+ end
232
+
233
+ # Approval prompt with session memory. Mirrors UI::API#confirm: a prior
234
+ # "session"/"always_*" decision (or a persisted prefix) for this scope —
235
+ # or its tool-wide parent — short-circuits the prompt so the same call
236
+ # isn't re-asked. Decisions are mapped to the SAME cache/persister actions
237
+ # the HTTP path uses, so CLE and API persist identical DERIVED RULES to
238
+ # `security.command_allowlist` for the "always" forms:
239
+ #
240
+ # :once — approve this call only (nothing remembered)
241
+ # :always_prefix — persist the derived PREFIX rule (offered only when a
242
+ # prefix is derivable AND the command isn't dangerous)
243
+ # :always_command — persist the NARROW rule (pattern key if dangerous,
244
+ # else the exact command); survives restart
245
+ # :always_tool — CLI-ONLY convenience: remember the whole tool for the
246
+ # session (never an HTTP decision, never persisted)
247
+ # :no — deny this call
248
+ #
249
+ # @param scope [String, nil] "<tool>:<command>" cache key from the
250
+ # caller. Nil opts out of memory (legacy callers still get a prompt).
251
+ # @param tool [String, nil] tool name, for rule derivation.
252
+ # @param command [String, nil] literal command/args, for prefix derivation.
253
+ # @param pattern_key [String, nil] matched dangerous-pattern key, if any.
254
+ # @param description [String, nil] dangerous-pattern description, if any.
255
+ # @return [Boolean] true when approved.
256
+ def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil)
257
+ return true if approval_cached?(scope)
258
+
259
+ # Finalize any live streaming state before the approval card so the card
260
+ # header doesn't glue onto it ("thinking…⚠ shell wants:" or a
261
+ # reasoning tail like "Let me run this.⚠ shell wants…"). The model
262
+ # emits reasoning/content right up to the tool call, so the transient
263
+ # indicator or the in-progress stream tail is still on the current line
264
+ # when approval is requested. #finalize_stream commits the tail and
265
+ # clears the indicator, mirroring a normal stream_end.
266
+ finalize_stream
267
+
268
+ # Attention: the run is now parked on a human decision — ring the
269
+ # bell/hook so an approval can't sit unseen behind a quiet terminal.
270
+ notifier.needs_approval(question.to_s)
271
+
272
+ # ⚠ is the attention glyph (P7): ◆ belongs to the animated status row.
273
+ rule = derive_rule(tool, command, pattern_key)
274
+ $stdout.puts @pastel.yellow("⚠ #{question}")
275
+ # The danger annotation is the single most safety-relevant line on the
276
+ # card, so it must be the MOST prominent — red + bold, not dim (#83).
277
+ $stdout.puts @pastel.red.bold(" ⚠ #{description}") unless description.to_s.empty?
278
+
279
+ choice = approval_choice(rule, tool: tool)
280
+ approved = apply_choice(choice, scope: scope, command: command, rule: rule)
281
+ # First plain "Approve once" of the session: point at the session-scope
282
+ # menu options so a multi-edit refactor doesn't keep interrupting
283
+ # without the user knowing it can stop (#110). Presentation only — the
284
+ # approval model is untouched.
285
+ session_scope_tip(tool, choice) if approved
286
+ # A deny is a safety action: confirm explicitly that nothing ran, in the
287
+ # same red ✗ styling failed tools use, so "Done." can't be read as "ran"
288
+ # (#83). Approve/allow paths are unchanged.
289
+ denied(tool) unless approved
290
+ approved
291
+ end
292
+
293
+ # A destructive yes/No confirm — NOT the tool-approval menu (#218).
294
+ # Deleting a session or forgetting a fact is not a tool/command the model
295
+ # proposed, so the "Approve once / this command / this tool" vocabulary is
296
+ # wrong, and its highlighted default (Approve) turns a stray Enter or a
297
+ # piped answer into a data-loss. This defaults to **No**: blank/Esc/EOF and
298
+ # every non-interactive path (piped stdin) decline, and only an explicit
299
+ # "y"/"yes" proceeds. Returns true only when the user affirmatively agreed.
300
+ def confirm_destructive(question)
301
+ $stdout.puts @pastel.yellow("⚠ #{question}")
302
+ # Off a real terminal there is no one to answer; fail closed (decline)
303
+ # so a piped `n` — or any pipe at all — can never destroy (#218).
304
+ return false unless interactive_terminal?
305
+
306
+ answer = BottomComposer.run_in_terminal do
307
+ @prompt.yes?(@pastel.bold("Proceed?"), default: false)
308
+ end
309
+ !!answer
310
+ rescue TTY::Reader::InputInterrupt
311
+ # Esc / Ctrl-C mid-prompt: treat as decline, never destroy.
312
+ $stdout.puts
313
+ false
314
+ end
315
+
316
+ # One dim line, once per session, after the FIRST "Approve once" (#110):
317
+ # the "this tool (this session)" option already exists in the menu, but
318
+ # nothing surfaced it, so users approved every single edit by hand.
319
+ def session_scope_tip(tool, choice)
320
+ return unless choice == :once
321
+ return if @session_scope_tip_shown
322
+
323
+ @session_scope_tip_shown = true
324
+ label = tool.to_s.empty? ? "this tool" : tool
325
+ $stdout.puts @pastel.dim(
326
+ %(┄ tip: choose "Approve — this tool (this session)" to stop being asked for #{label} this session ┄)
327
+ )
328
+ end
329
+
330
+ # Explicit, visible confirmation that a denied command was NOT executed.
331
+ def denied(tool = nil)
332
+ label = tool ? "#{tool} command" : "command"
333
+ error("#{label} denied — not executed")
334
+ end
335
+
336
+ def separator
337
+ $stdout.puts @pastel.dim("─" * 80)
338
+ end
339
+
340
+ # Panel color diet (P8): dim label, PLAIN value, cyan reserved for the
341
+ # actionable pointer (`(use /mcp)`). The ljust width matches the
342
+ # /status grid so values line up in one column.
343
+ def panel_line(label, value, pointer: nil)
344
+ row = " #{@pastel.dim(label.to_s.ljust(10))} #{value}"
345
+ row += " #{@pastel.cyan(pointer)}" if pointer
346
+ $stdout.puts row
347
+ end
348
+
349
+ # Welcome-panel hint row (P8): the actionable command is the ONE cyan
350
+ # accent; its description stays plain.
351
+ def hint_row(command, description)
352
+ $stdout.puts " #{@pastel.cyan(command.to_s.ljust(9))} #{description}"
353
+ end
354
+
355
+ # --- Compact timeline rendering (M2) ---
356
+
357
+ # Activity started: renders as `● name` or `● name hint` — a QUIET dim
358
+ # row with only the ● in cyan. The tool frame is plumbing, not payload:
359
+ # a fully cyan "● running read · path" row outshouted the answer (P1).
360
+ def activity_started(name, hint: nil)
361
+ # Replace a still-showing "thinking…" indicator before the committed
362
+ # activity row so it isn't stranded above it (#86): the model emits the
363
+ # indicator during TTFB and may go straight to a tool call. Collapse any
364
+ # buffered reasoning into the cue/aside FIRST so a reasoning→tool turn
365
+ # (no answer text) never strands the thought.
366
+ collapse_reasoning
367
+ hint_str = hint ? " #{hint}" : ""
368
+ # ONE blank before the first frame of a tool run; frames inside a run
369
+ # butt together, and a gap left by the previous block isn't doubled (P3).
370
+ $stdout.puts unless %i[tool gap].include?(@last_block)
371
+ $stdout.puts "#{@pastel.cyan("●")} #{@pastel.dim("#{name}#{hint_str}")}"
372
+ @activity_open = true
373
+ @activity_name = name
374
+ @last_block = :tool
375
+ reset_tool_preview
376
+ end
377
+
378
+ # Activity finished. Success is QUIET and compact: `└ ✓ 11 lines` — the
379
+ # ✓ already says "done" and the opener row said the name, so repeating
380
+ # both was noise (P10); dim, not green — color is reserved for the one
381
+ # outcome that needs eyes (P1). Failure keeps name + wording, in red:
382
+ # `└ ✗ failed · shell · exit 1` — the word must agree with the glyph;
383
+ # "✗ done" read as if the errored tool had still succeeded (#153).
384
+ def activity_finished(name, metric: nil, failed: false)
385
+ @activity_open = false
386
+ flush_tool_preview_overflow
387
+ # The metric can carry newlines (e.g. a task_result body): interpolating
388
+ # it raw would continue flush-left and unstyled on the next lines —
389
+ # inline it into the ONE styled row instead.
390
+ inline = metric ? truncate_inline(metric, 120) : nil
391
+ if failed
392
+ suffix = inline && !inline.empty? ? " · #{inline}" : ""
393
+ $stdout.puts @pastel.red(" └ ✗ failed · #{name}#{suffix}")
394
+ else
395
+ suffix = inline && !inline.empty? ? " #{inline}" : ""
396
+ $stdout.puts @pastel.dim(" └ ✓#{suffix}")
397
+ end
398
+ @last_block = :tool
399
+ end
400
+
401
+ # Approval requested: renders as `◆ summary`
402
+ def approval_requested(summary:, choices:)
403
+ $stdout.puts
404
+ $stdout.puts @pastel.yellow("◆ #{summary}")
405
+ choices.each do |choice|
406
+ $stdout.puts @pastel.dim(" [#{choice[:key]}] #{choice[:label]}")
407
+ end
408
+ end
409
+
410
+ # Body text rendered with modest indentation (no big box).
411
+ def body(text)
412
+ return if text.nil? || text.to_s.empty?
413
+
414
+ text.each_line do |line|
415
+ $stdout.puts " #{line.chomp}"
416
+ end
417
+ end
418
+
419
+ # A turn that ends in ERROR must tear down the live "thinking…" animation
420
+ # (and any open stream) BEFORE the error line prints — otherwise the
421
+ # ticking row strands below the error and keeps interleaving into every
422
+ # subsequent print until a full repaint (#74). The success path settles
423
+ # via stream_end/collapse_reasoning; this gives the error path the same
424
+ # cleanup. Idempotent — a no-op for errors printed outside a turn.
425
+ def error(message)
426
+ finalize_stream
427
+ # An error tears the turn-scoped status row down entirely (#74): the
428
+ # next model attempt (retry/fallback) restarts it via thinking_started.
429
+ status_stop
430
+ @thinking_indicator = false
431
+ super
432
+ end
433
+
434
+ # One-shot suppression of the next `⎿ interrupted` marker (#111). The
435
+ # chat loop sets it when a slash-command submit interrupted a turn with
436
+ # nothing visibly in flight (no stream, no live partial — e.g. only a
437
+ # subagent card animating): the turn LOOKED idle, so the marker would
438
+ # read as a stray artifact above the command's own output. Consumed by
439
+ # #turn_interrupted; the chat loop resets it at each turn start so a
440
+ # suppression that never fired can't leak into a later real Ctrl+C.
441
+ def suppress_interrupt_marker(value: true)
442
+ @suppress_interrupt_marker = value
443
+ end
444
+
445
+ # Commits the standardized interrupt marker right after the partial answer
446
+ # that was kept when a turn is cancelled (Ctrl+C, or the interrupt-by-
447
+ # default Enter): a dim `⎿ interrupted` row, house grammar. Leading CR +
448
+ # clear-line so it lands cleanly even if the cursor is sitting after a
449
+ # partial stream chunk. This is the single visible interrupt notice — the
450
+ # runner no longer also prints a separate "interrupted by user" warning.
451
+ # Tears down a still-ticking "thinking…" animation first, same as the
452
+ # error path (#74) — Loop#stream_end usually already did, but an
453
+ # interrupt raised outside the streaming bracket must settle too.
454
+ # Swallowed once after a QUIET slash-command interrupt (#111, above).
455
+ def turn_interrupted
456
+ finalize_stream
457
+ # Interrupt = turn end for the status row: kill the engine thread.
458
+ status_stop
459
+ @thinking_indicator = false
460
+ if @suppress_interrupt_marker
461
+ @suppress_interrupt_marker = false
462
+ return
463
+ end
464
+
465
+ clear_line
466
+ $stdout.puts @pastel.dim(" ⎿ interrupted")
467
+ $stdout.flush
468
+ end
469
+
470
+ # Free-line annotation rendered as `┄ message ┄`, dim.
471
+ def note(text)
472
+ return if text.nil? || text.to_s.empty?
473
+
474
+ $stdout.puts unless @last_block == :gap
475
+ $stdout.puts @pastel.dim("┄ #{text} ┄")
476
+ @last_block = :other
477
+ end
478
+
479
+ # The STATIC turn footer rail, all dim: `┄ turn · 16.6s · 3 tools ┄`.
480
+ # No red ◆ — red is the error color; the animated status row keeps its
481
+ # red facet as the living brand mark (P4). Attached directly under the
482
+ # answer with no leading blank (P3). Subagent completions stashed
483
+ # mid-turn (#subagent_finished) fold into the grammar instead of
484
+ # stacking a second `┄ ┄` rail right at turn end:
485
+ # ┄ turn · 16.6s · 3 tools · 105 tok · sa_e488 done ┄
486
+ def turn_footer(text)
487
+ pending = Array(@pending_subagent_footers)
488
+ @pending_subagent_footers = nil
489
+ line = ([text] + pending.map { |p| p[:fold] }).join(" · ")
490
+ $stdout.puts @pastel.dim("┄ #{line} ┄")
491
+ @last_block = :other
492
+ end
493
+
494
+ # A background subagent reached a terminal state. Mid-turn the one-line
495
+ # summary is STASHED and folded into the turn footer (P4) so two `┄ ┄`
496
+ # rails never stack at turn end (the report still reaches the model via
497
+ # the InputQueue notice, rendered by #input_injected); between turns the
498
+ # full lifecycle block renders immediately.
499
+ def subagent_finished(line, id: nil, status: "done", report: nil)
500
+ if @turn_active && id
501
+ (@pending_subagent_footers ||= []) << { fold: "#{id} #{status}",
502
+ line: line, status: status, report: report, id: id }
503
+ else
504
+ subagent_lifecycle(line, status: status, report: report, id: id)
505
+ end
506
+ end
507
+
508
+ # ONE lifecycle grammar (P6): the live-card-shaped row
509
+ # (`▸ sa_e488 · explore · completed · 1 tool · 12s`) — dim; red only on
510
+ # failure — and the child's FULL report markdown-rendered under its own
511
+ # `↳ report:` lead (the #139 fold-in treatment), never amputated to a
512
+ # one-line head. The id is remembered so the completion notice the model
513
+ # receives next turn doesn't ECHO the same report a second time
514
+ # (#input_injected elides the already-shown Result body).
515
+ def subagent_lifecycle(line, status: "done", report: nil, id: nil)
516
+ $stdout.puts unless @last_block == :gap
517
+ $stdout.puts(status == "failed" ? @pastel.red(line) : @pastel.dim(line))
518
+ if report && !report.to_s.strip.empty?
519
+ $stdout.puts @pastel.dim(" ↳ report:")
520
+ commit_markdown_block(report)
521
+ remember_reported_subagent(id)
522
+ end
523
+ @last_block = :other
524
+ end
525
+
526
+ # Commits the ⛔ "a subagent needs you" attention banner into scrollback the
527
+ # instant a background child escalates an ask_parent to the human. This is
528
+ # the ATTENTION event (the one-time, unmissable banner); the persistent
529
+ # AMBIENT reminder is the ⛔ card line the live region keeps showing (see
530
+ # UI::SubagentCards#hint_line) so a blocked tree can never hide behind a
531
+ # spinner. The answer verb is /reply <id>; --stop cancels the child. Routed
532
+ # through $stdout so (during a turn) it lands above the bottom composer like
533
+ # every other committed line; between turns it prints inline.
534
+ def subagent_ask_banner(id, subagent, question)
535
+ $stdout.puts
536
+ $stdout.puts @pastel.dim("┄ a subagent needs you ┄")
537
+ $stdout.puts @pastel.red.bold("⛔ #{id} (#{subagent}) is BLOCKED, waiting on your answer")
538
+ $stdout.puts @pastel.yellow(" ❓ #{question}")
539
+ $stdout.puts @pastel.dim(" everything it needs is paused until you answer — #{ask_timeout_hint}")
540
+ $stdout.puts @pastel.dim(" → /reply #{id} <answer> to answer · /agents #{id} --stop to cancel")
541
+ $stdout.flush
542
+ # The ⛔ state is the loudest one — the whole subtree is parked on the
543
+ # human — so it also rings the attention bell/hook.
544
+ notifier.blocked("#{id} (#{subagent}) is waiting on your answer")
545
+ end
546
+
547
+ # The honest bound for the ⛔ banner: a blocking ask_parent waits at most
548
+ # tasks.ask_parent_timeout seconds, then the child proceeds with its best
549
+ # judgement (ask_parent_tool.rb). The banner must say so — "no timeout" was
550
+ # a lie unless the bound is explicitly disabled (nil/0) in config (#145).
551
+ def ask_timeout_hint
552
+ seconds = Rubino.configuration.tasks_ask_parent_timeout.to_i
553
+ return "no timeout" unless seconds.positive?
554
+
555
+ human = (seconds % 60).zero? ? "#{seconds / 60}m" : "#{seconds}s"
556
+ "auto-resumes with its best judgement in #{human}"
557
+ end
558
+
559
+ # Renders an ephemeral `probe` answer in the dim, fenced aside that the
560
+ # locked UX prescribes: an opening `┄ probe (ephemeral · not saved) ┄`
561
+ # rail, the answer body on a dim `┊` left-rail, then a closing
562
+ # `┄ vanished · main thread untouched ┄` rail. The whole block is dim and
563
+ # never enters scrollback as a "real" answer — it is the visual contract
564
+ # that nothing here was saved. Same render family as #note / #mode_changed.
565
+ def probe_aside(answer)
566
+ $stdout.puts
567
+ $stdout.puts @pastel.dim("┄ probe (ephemeral · not saved) ┄#{"─" * 28}")
568
+ answer.to_s.each_line do |line|
569
+ $stdout.puts @pastel.dim("┊ #{line.chomp}")
570
+ end
571
+ $stdout.puts @pastel.dim("┄ vanished · main thread untouched ┄#{"─" * 25}")
572
+ $stdout.puts
573
+ end
574
+
575
+ # Confirms a `/branch` fork in the dim block from the locked UX: the new
576
+ # session id + title, the parent it inherits from, and the literal way
577
+ # back (`/sessions <parent>`), bracketed by `┄ branched ┄` / `┄ now in
578
+ # <id> ┄` rails. The CLI flips the prompt chip to `branch:<id> ❯` after.
579
+ def branch_confirmation(new_id:, parent_id:, title:, included_probe:)
580
+ short_new = new_id.to_s[0..3]
581
+ short_parent = parent_id.to_s[0..3]
582
+ seed = "inherits #{short_parent} ▸ up to here"
583
+ seed += " + the probe above" if included_probe
584
+ $stdout.puts
585
+ $stdout.puts @pastel.dim("┄ branched ┄#{"─" * 50}")
586
+ label = title.to_s.strip.empty? ? "" : %( "#{title}")
587
+ $stdout.puts @pastel.dim("┊ new session #{short_new}#{label}")
588
+ $stdout.puts @pastel.dim("┊ #{seed}")
589
+ $stdout.puts @pastel.dim("┊ original #{short_parent} left intact — /sessions #{short_parent} to return")
590
+ $stdout.puts @pastel.dim("┄ now in #{short_new} ┄#{"─" * 42}")
591
+ $stdout.puts
592
+ end
593
+
594
+ # Repaints the SUBAGENT CARD block in the live region from the
595
+ # BackgroundTasks registry (Variant A). Called whenever a background
596
+ # subagent's activity changes (a child tool started/finished, a spawn, a
597
+ # completion, an approval request) so the collapsed cards update IN PLACE
598
+ # without flooding scrollback. Renders the registry's CURRENT live snapshot
599
+ # rather than a single delta, so cards added/removed/updated all converge.
600
+ #
601
+ # The card block only exists while a turn owns the bottom composer
602
+ # (BottomComposer.current); between turns there is no live region, so this
603
+ # is a quiet no-op (the /agents drill-in covers the idle case). Reads the
604
+ # registry under its own mutex via #running; the formatting is pure.
605
+ def set_subagent_cards
606
+ composer = BottomComposer.current
607
+ return unless composer
608
+
609
+ entries = Tools::BackgroundTasks.instance.running
610
+ composer.set_cards(subagent_cards.card_lines(entries))
611
+ rescue StandardError
612
+ # A card repaint is cosmetic — never let it break the turn or the child.
613
+ end
614
+
615
+ def subagent_cards
616
+ @subagent_cards ||= SubagentCards.new(pastel: @pastel)
617
+ end
618
+
619
+ # Echoes a line the user typed mid-turn, parked for the next turn.
620
+ # Rendered dim on its own line, prefixed `▸`, so the steered text stays
621
+ # visible without competing with the streaming assistant output. Starts
622
+ # with a CR + clear-line so it lands cleanly even if the cursor is
623
+ # sitting after a partial stream chunk.
624
+ def queued(text)
625
+ return if text.nil? || text.to_s.empty?
626
+
627
+ clear_line
628
+ $stdout.puts @pastel.dim("queued ▸ #{text}")
629
+ $stdout.flush
630
+ end
631
+
632
+ # Confirms text the loop picked up mid-turn and injected into the CURRENT
633
+ # turn (Phase-2 steering). Rendered dim on its own line, prefixed `↳`, so
634
+ # the user sees their interjection landed without it competing with the
635
+ # streaming assistant output. Leading CR + clear-line so it sits cleanly
636
+ # even if the cursor is mid-stream-chunk.
637
+ #
638
+ # A multi-line injection (a `[background-task] … Result:` completion
639
+ # notice carrying the child's markdown report) keeps the dim `↳` prefix
640
+ # on its FIRST line only; the body renders through the same markdown
641
+ # pipeline as assistant answers, so the child's report shows styled
642
+ # headings/bold instead of literal `##`/`**` (#139).
643
+ #
644
+ # An injected line that carried a live "⏳ queued:" indicator (an
645
+ # Alt+Enter / "/queued" item the loop folded into the current turn) has
646
+ # been CONSUMED — drop its indicator, or it would sit above the input
647
+ # forever for a message that already ran (#129).
648
+ def input_injected(text)
649
+ return if text.nil? || text.to_s.empty?
650
+
651
+ if (composer = BottomComposer.current)
652
+ # The loop coalesces several drained lines into one injection — match
653
+ # the whole text AND each line so every consumed indicator clears.
654
+ composer.commit_queued(text)
655
+ text.to_s.split("\n").each { |line| composer.commit_queued(line) }
656
+ end
657
+ clear_line
658
+ first, rest = elide_shown_reports(text.to_s).split("\n", 2)
659
+ $stdout.puts @pastel.dim("↳ received while working: #{first}")
660
+ commit_markdown_block(rest) if rest && !rest.strip.empty?
661
+ $stdout.flush
662
+ end
663
+
664
+ # Drops the Result body from a completion notice whose report the
665
+ # lifecycle block ALREADY rendered in full (#subagent_lifecycle), so the
666
+ # user doesn't read the same report twice — once at completion and again
667
+ # when the queued notice is injected next turn. DISPLAY-ONLY: the
668
+ # model-facing injected text is untouched. Anchored to the notice shape
669
+ # TaskTool#completion_notice emits; an unmatched notice renders whole
670
+ # (duplicated beats lost). Each id is consumed on first elision.
671
+ def elide_shown_reports(text)
672
+ ids = @reported_subagent_ids
673
+ return text if ids.nil? || ids.empty?
674
+
675
+ ids.dup.each do |id|
676
+ quoted = Regexp.escape(id)
677
+ pattern = Regexp.new(
678
+ "^(\\[background-task\\] Task #{quoted} \\([^)]*\\) completed\\.)\n" \
679
+ "Result:\n.*?\n\\(full result via task_result\\(\"#{quoted}\"\\)\\)",
680
+ Regexp::MULTILINE
681
+ )
682
+ replaced = text.sub(pattern) do
683
+ "#{::Regexp.last_match(1)} (report shown above — full result via task_result(\"#{id}\"))"
684
+ end
685
+ next if replaced == text
686
+
687
+ text = replaced
688
+ ids.delete(id)
689
+ end
690
+ text
691
+ end
692
+
693
+ # Bounded memory of lifecycle-rendered report ids (see #elide_shown_reports).
694
+ def remember_reported_subagent(id)
695
+ return unless id
696
+
697
+ @reported_subagent_ids ||= []
698
+ @reported_subagent_ids << id.to_s
699
+ @reported_subagent_ids.shift while @reported_subagent_ids.size > 32
700
+ end
701
+
702
+ # Markdown rendering: assistant output rendered as readable text with
703
+ # modest indentation, no box.
704
+ def assistant_text(text)
705
+ return if text.nil? || text.to_s.empty?
706
+
707
+ # A progress indicator must be REPLACED by its result, never left as
708
+ # residue above the answer (#86). On the non-streaming path nothing
709
+ # else clears the transient "thinking…" line before the committed
710
+ # answer, so collapse any buffered reasoning + clear the animation first.
711
+ collapse_reasoning
712
+ answer_gap
713
+ commit_markdown_block(text)
714
+ end
715
+
716
+ # Exactly ONE blank line before the answer payload (P3) — skipped when
717
+ # the previous committed block already left a gap open. No trailing
718
+ # blank: the turn footer attaches directly under the answer. Shared by
719
+ # the non-streamed (#assistant_text) and streamed (#stream) paths so
720
+ # both turns read identically.
721
+ def answer_gap
722
+ $stdout.puts unless @last_block == :gap
723
+ @last_block = :answer
724
+ end
725
+
726
+ # The left margin every committed markdown line is printed behind. The
727
+ # live tail (#show_live_tail) reuses it so the raw in-flight lines sit in
728
+ # the SAME column as the rendered block they become — a flush-left tail
729
+ # under indented committed output read as a jarring seam.
730
+ MD_MARGIN = " "
731
+
732
+ # Renders a markdown string to committed, styled lines above the composer
733
+ # (each line as `$stdout.puts "#{MD_MARGIN}#{line}"`). Shared by
734
+ # #assistant_text and the per-block streaming path so both apply the
735
+ # identical rendering.
736
+ def commit_markdown_block(text)
737
+ return if text.nil? || text.to_s.empty?
738
+
739
+ render_markdown_block(text).each { |line| $stdout.puts "#{MD_MARGIN}#{line}" }
740
+ end
741
+
742
+ # A markdown string -> Array<String> of ANSI-styled lines (no indent).
743
+ # Tables are fit to the terminal width minus the 2-space indent that
744
+ # #commit_markdown_block adds, so wide tables wrap instead of overflowing.
745
+ def render_markdown_block(text)
746
+ MarkdownRenderer.new(width: markdown_width).render(text).map do |line_tokens|
747
+ line_tokens.map do |token, style|
748
+ style.nil? ? token : apply_style(token, style)
749
+ end.join
750
+ end
751
+ end
752
+
753
+ # Smallest usable markdown/table budget. Below this a streamed table's
754
+ # columns collapse to ~1 char each (#95), so we floor here rather than at 1.
755
+ MIN_MARKDOWN_WIDTH = 40
756
+
757
+ # How many trailing lines of the in-flight block stay visible live (#127).
758
+ LIVE_TAIL_ROWS = 3
759
+
760
+ # A spawn handle: the verbose model-facing acknowledgement the task tool
761
+ # returns for a BACKGROUND child. The model needs the whole instruction;
762
+ # the human only needs "it started".
763
+ SPAWN_HANDLE_RE = /\AStarted background subagent '([^']+)' as task (\S+?)\.(?:\s|\z)/
764
+
765
+ # Column budget for markdown rendering: terminal width minus the MD_MARGIN
766
+ # indent applied to every committed line. Headless-safe (falls back to 80).
767
+ #
768
+ # `winsize` can under-report during the bottom-composer raw-mode TUI while a
769
+ # table is still streaming, returning a tiny/zero column count (#95). Treat
770
+ # any non-positive width as "unknown" and fall back to 80, and never let the
771
+ # budget drop below MIN_MARKDOWN_WIDTH, so columns stay readable mid-stream.
772
+ def markdown_width
773
+ cols = begin
774
+ IO.console&.winsize&.last
775
+ rescue StandardError
776
+ nil
777
+ end
778
+ cols = 80 unless cols&.positive?
779
+ [cols - MD_MARGIN.length, MIN_MARKDOWN_WIDTH].max
780
+ end
781
+
782
+ # --- Streaming (unchanged except visual, now uses assistant_text) ---
783
+
784
+ def stream(chunk)
785
+ type = chunk[:type] || :content
786
+ text = chunk[:text].to_s
787
+ return if text.empty?
788
+
789
+ @turn_tok_chars += text.length if @turn_active
790
+
791
+ # Reasoning deltas are NEVER raw-printed (that dumped unstyled reasoning
792
+ # indistinguishable from the answer). Buffer them so the collapse cue /
793
+ # full aside / ctrl-o reveal can render them in house style instead. The
794
+ # status row keeps animating (label "thinking") while reasoning
795
+ # accumulates — and RESUMES if a tool/content block hid it (P4).
796
+ if type == :thinking
797
+ @reasoning_buffer << text
798
+ @thinking_started_at ||= monotonic_now
799
+ if @turn_active && thinking_painter
800
+ @thinking_indicator = true
801
+ status_ensure("thinking", phase: :thinking)
802
+ end
803
+ return
804
+ end
805
+
806
+ # First answer token: collapse any buffered reasoning into scrollback
807
+ # (cue or aside per mode) before the answer streams below it. The
808
+ # status row hides while answer text streams — the live tail owns the
809
+ # transient row until the block ends.
810
+ collapse_reasoning if @thinking_indicator || !@reasoning_buffer.empty?
811
+ clear_thinking_indicator
812
+
813
+ if type != @stream_type
814
+ stream_end if @stream_type
815
+ @stream_type = type
816
+ # The streamed answer gets the SAME single committed gap the
817
+ # non-streamed path gets (P3) — once, when the content stream opens.
818
+ answer_gap if type == :content
819
+ end
820
+
821
+ # Signal the bottom composer that ANSWER content is now actively
822
+ # streaming so it defers a mid-stream Ctrl+O reveal (D1) instead of
823
+ # bisecting the answer. Thinking deltas never reach here (they return
824
+ # early above), so the thinking phase stays "not streaming" and its
825
+ # commits still land cleanly above.
826
+ mark_content_streaming(true)
827
+ stream_content(text)
828
+ end
829
+
830
+ def stream_end
831
+ clear_thinking_indicator
832
+ if @stream_type == :content && @stream_md
833
+ flush_content_stream
834
+ elsif @stream_type
835
+ $stdout.puts
836
+ end
837
+ @stream_md = nil
838
+ @stream_type = nil
839
+ # The answer block is finished: tell the composer to flush any reveal
840
+ # that was deferred during the stream so the `┊` aside renders cleanly
841
+ # AFTER the answer (D1).
842
+ mark_content_streaming(false)
843
+ end
844
+
845
+ # Block boundary on the STREAMING path, driven by the adapter's
846
+ # after_message callback (one assistant message == one content block; on
847
+ # a multi-step tool turn several blocks stream within one model call).
848
+ # Commits the in-flight block's tail and clears @stream_type so the
849
+ # status row can resume between blocks (the P4 inter-tool gap) and a
850
+ # later #thinking_started isn't gated out by a stale open stream.
851
+ # Idempotent: a no-op when no stream is open (non-streaming path, or the
852
+ # boundary for a block that carried no content).
853
+ def stream_block_end(_message_id = nil)
854
+ return unless @stream_type
855
+
856
+ stream_end
857
+ return unless @turn_active && thinking_painter
858
+
859
+ @thinking_indicator = true
860
+ status_ensure("thinking", phase: :thinking)
861
+ end
862
+
863
+ # Repaint cadence for the status-row animation (seconds).
864
+ STATUS_TICK = 0.1
865
+ # "Ruby facet" skin: a red ◆ sweeping back and forth on a 5-cell dim ┄
866
+ # track (the house separator glyph). 12-frame loop @100ms — the facet
867
+ # dwells one extra beat at each end of the sweep.
868
+ FACET_TRACK_CELLS = 5
869
+ FACET_FRAMES = [0, 0, 0, 1, 2, 3, 4, 4, 4, 3, 2, 1].freeze
870
+ # Don't nag fast turns: the "enter to interrupt" hint appears only after
871
+ # the wait has visibly dragged.
872
+ INTERRUPT_HINT_AFTER = 1.5
873
+
874
+ # Marks the start of a TURN: resets the per-turn stats and starts the
875
+ # status-row engine in its initial "thinking" phase (the P1 wait). Called
876
+ # by the chat loop right before the runner takes over; guarded with
877
+ # respond_to? at the call site so other UI adapters are unaffected.
878
+ def turn_started
879
+ @turn_active = true
880
+ @turn_started_at = monotonic_now
881
+ @turn_tool_count = 0
882
+ @turn_tok_chars = 0
883
+ @thinking_indicator = true if thinking_painter
884
+ status_show("thinking", phase: :thinking)
885
+ end
886
+
887
+ # Marks the end of a TURN (normal completion, error, or interrupt): the
888
+ # one place the turn-scoped ticker thread is allowed to die.
889
+ def turn_finished
890
+ elapsed = @turn_active && @turn_started_at ? monotonic_now - @turn_started_at : nil
891
+ @turn_active = false
892
+ @thinking_indicator = false
893
+ status_stop
894
+ # A completion stashed after the footer printed (or on an interrupted
895
+ # turn that never got one) must not vanish — flush the full block.
896
+ pending = Array(@pending_subagent_footers)
897
+ @pending_subagent_footers = nil
898
+ pending.each do |p|
899
+ subagent_lifecycle(p[:line], status: p[:status] || "done", report: p[:report], id: p[:id])
900
+ end
901
+ # Attention signal LAST, with the footer already committed: a LONG
902
+ # turn rings the bell/hook so a human who looked away comes back;
903
+ # quick turns stay silent (the notifier's min_turn_seconds gate).
904
+ notifier.turn_finished(elapsed) if elapsed
905
+ end
906
+
907
+ # Shows the status row during the model wait. Mid-turn this only swaps
908
+ # the label back to "thinking" (the engine thread is already running);
909
+ # for a stand-alone wait with no turn bracket — the /probe side-inference
910
+ # (#58) — it starts the engine fresh. Frames go through #paint_live, so
911
+ # mid-turn they pass the composer's render mutex; on a BARE TTY with no
912
+ # #live seam the row repaints in place via CR + clear-line. Into a pipe
913
+ # it stays a single static dim print — never animate into a non-terminal.
914
+ def thinking_started
915
+ return if @stream_type
916
+
917
+ @thinking_started_at ||= monotonic_now
918
+ unless thinking_painter
919
+ return if @thinking_indicator
920
+
921
+ @thinking_indicator = true
922
+ $stdout.print @pastel.dim("thinking…")
923
+ $stdout.flush
924
+ return
925
+ end
926
+
927
+ @thinking_indicator = true
928
+ status_ensure("thinking", phase: :thinking)
929
+ end
930
+
931
+ # Clears the status row for callers that bracket a synchronous wait with
932
+ # no stream lifecycle of their own — the /probe side-inference (#58).
933
+ # Public counterpart to #thinking_started; a no-op when nothing is
934
+ # showing. Outside a turn this also stops the engine thread.
935
+ def thinking_finished
936
+ clear_thinking_indicator
937
+ status_stop unless @turn_active
938
+ end
939
+
940
+ # Holds text the user typed during a synchronous /probe wait (#221), so the
941
+ # next idle prompt seeds it back into `❯` — the wait owns a transient
942
+ # composer to echo input, but it's torn down before the REPL reopens its
943
+ # idle composer, so the buffer is parked here in between.
944
+ def stash_probe_draft(text)
945
+ @probe_draft = text
946
+ end
947
+
948
+ # Consumes the parked /probe draft (see #stash_probe_draft), or nil.
949
+ def take_probe_draft
950
+ draft = @probe_draft
951
+ @probe_draft = nil
952
+ draft
953
+ end
954
+
955
+ def monotonic_now
956
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
957
+ end
958
+
959
+ # The per-frame paint strategy for the thinking animation, or nil when
960
+ # the output can't host one (a pipe with no composer). Frames go through
961
+ # #paint_live, which re-resolves the right seam on EVERY frame — so a
962
+ # ticker that outlives a composer/proxy swap can never paint through a
963
+ # stale handle (#169).
964
+ def thinking_painter
965
+ return unless $stdout.respond_to?(:live) || BottomComposer.current || tty_stdout?
966
+
967
+ method(:paint_live)
968
+ end
969
+
970
+ # Paints (or, with an empty +frame+, clears) the ONE transient live row
971
+ # through whichever seam owns the bottom of the screen, resolved per call:
972
+ # * during a turn $stdout is the StdoutProxy — #live replaces the
973
+ # composer's transient row under its render mutex;
974
+ # * an ACTIVE composer without the proxy is painted via
975
+ # BottomComposer#set_partial — same row, same mutex — NEVER with a raw
976
+ # CR repaint that would clobber the pinned prompt line (#169);
977
+ # * a bare TTY with no composer (the cooked /probe wait, #58; one-shot)
978
+ # repaints in place via CR + clear-line;
979
+ # * a pipe hosts nothing — raw escapes must not leak into the cooked
980
+ # output (#56).
981
+ def paint_live(frame)
982
+ if $stdout.respond_to?(:live)
983
+ $stdout.live(frame)
984
+ elsif (composer = BottomComposer.current)
985
+ composer.set_partial(frame)
986
+ elsif tty_stdout?
987
+ # The bare-TTY repaint owns ONE row (CR + clear-line): show only the
988
+ # last line of a multi-line frame so the in-place repaint can't wrap
989
+ # and leave residue it can never erase.
990
+ $stdout.print("\r\e[2K#{frame.to_s.split("\n").last}")
991
+ $stdout.flush
992
+ end
993
+ end
994
+
995
+ # True when $stdout is a real terminal (guarded for IO doubles).
996
+ def tty_stdout?
997
+ $stdout.respond_to?(:tty?) && $stdout.tty?
998
+ rescue StandardError
999
+ false
1000
+ end
1001
+
1002
+ # In-place clear of the current row (CR + erase-line) before a committed
1003
+ # line lands. Purely a cursor-positioning nicety, so it is gated on a real
1004
+ # TTY: into a pipe there is no cursor and the raw `\e[2K` would leak as
1005
+ # literal bytes into the cooked output (#56).
1006
+ def clear_line
1007
+ return unless tty_stdout?
1008
+
1009
+ $stdout.print("\r\e[2K")
1010
+ end
1011
+
1012
+ # The active reasoning render mode (:hidden | :collapsed | :full), resolved
1013
+ # from config (which /reasoning writes to, so the adapter gate and this
1014
+ # render path share one source of truth). Handles the legacy show_reasoning
1015
+ # back-compat mapping.
1016
+ def reasoning_mode
1017
+ Config::ReasoningPrefs.mode(Rubino.configuration)
1018
+ end
1019
+
1020
+ # Whole seconds the current/last thinking phase ran, for the collapse cue.
1021
+ def thinking_elapsed_seconds
1022
+ return 0 unless @thinking_started_at
1023
+
1024
+ (monotonic_now - @thinking_started_at).to_i
1025
+ end
1026
+
1027
+ # Replay user input in compact form
1028
+ def replay_user_input(text, at: nil)
1029
+ $stdout.puts
1030
+ $stdout.puts @pastel.green("#{text}")
1031
+ $stdout.puts
1032
+ @last_block = :gap
1033
+ end
1034
+
1035
+ # Tool started renders as the quiet `● name hint` open row (P1).
1036
+ # The `task` (delegation) tool gets a dedicated row so the timeline reads
1037
+ # as a hand-off, not a generic tool call: `● delegated → <subagent> <prompt>`.
1038
+ #
1039
+ # Finalize any OPEN content stream first (#136): on the streaming path the
1040
+ # model can emit answer text right up to the tool call (ruby_llm runs the
1041
+ # tool mid-stream, so no stream_end intervenes). Without this the pre-tool
1042
+ # text stayed buffered in the stream splitter, committed only AFTER the
1043
+ # tool card, glued straight onto the post-tool continuation
1044
+ # ("…number.Confirmed — …"). Committing it here preserves stream order
1045
+ # (text → tool card → text) and the block boundary between the segments.
1046
+ # Idempotent: the non-streaming path already closed the stream
1047
+ # (Loop#close_intermediate_stream), so this is a no-op there — the same
1048
+ # contract #confirm uses before the approval card.
1049
+ def tool_started(name, arguments: nil, at: nil)
1050
+ finalize_stream
1051
+ return delegation_started(arguments) if name == "task"
1052
+
1053
+ hint = args_hint(arguments)
1054
+ activity_started(name, hint: hint)
1055
+ # The committed `● name` open row is in scrollback; SWITCH the status-row
1056
+ # label to the tool (P3) instead of leaving the live region dead while
1057
+ # the tool runs. The engine thread stays the same — label swap only.
1058
+ status_show(name, phase: :tool, hint: status_hint(arguments)) if @turn_active
1059
+ end
1060
+
1061
+ # DISPLAY-ONLY collapse (P2): the transcript shows the head few lines of
1062
+ # a tool's output plus a `… +N lines (full output → context)` marker —
1063
+ # the FULL output still goes to the model/context unchanged. Governed by
1064
+ # display.tool_output_preview_lines (0 = old full dump).
1065
+ def tool_body(text, kind: :plain)
1066
+ return if text.nil? || text.to_s.empty?
1067
+
1068
+ limit = tool_preview_limit
1069
+ lines = text.to_s.lines
1070
+ shown = limit.positive? ? lines.first(limit) : lines
1071
+ hidden = lines.size - shown.size
1072
+ write_body_lines(shown.join) do |chomped|
1073
+ if kind == :diff
1074
+ case chomped[0]
1075
+ when "+" then @pastel.green(chomped)
1076
+ when "-" then @pastel.red(chomped)
1077
+ else @pastel.dim(chomped)
1078
+ end
1079
+ else
1080
+ @pastel.dim(chomped)
1081
+ end
1082
+ end
1083
+ $stdout.puts @pastel.dim(" #{hidden_lines_marker(hidden)}") if hidden.positive?
1084
+ @last_block = :tool
1085
+ end
1086
+
1087
+ # Streamed tool output (shell): same display-only collapse as #tool_body,
1088
+ # accumulated across chunks. Lines past the preview budget are counted
1089
+ # silently; #activity_finished flushes the `… +N lines` marker right
1090
+ # before the close row.
1091
+ def tool_chunk(_name, chunk)
1092
+ return if chunk.nil? || chunk.to_s.empty?
1093
+
1094
+ limit = tool_preview_limit
1095
+ unless limit.positive?
1096
+ write_body_lines(chunk.to_s) { |chomped| @pastel.dim(chomped) }
1097
+ return
1098
+ end
1099
+
1100
+ chunk.to_s.each_line do |line|
1101
+ if @tool_preview_shown.to_i < limit
1102
+ @tool_preview_shown = @tool_preview_shown.to_i + 1
1103
+ write_body_lines(line) { |chomped| @pastel.dim(chomped) }
1104
+ else
1105
+ @tool_preview_hidden = @tool_preview_hidden.to_i + 1
1106
+ end
1107
+ end
1108
+ @last_block = :tool
1109
+ end
1110
+
1111
+ # Tool finished renders as the compact `└ ✓ metric` close row, or
1112
+ # `└ ✗ failed · name · error` in red (P10).
1113
+ # The `task` tool closes the delegation row: `✓ <subagent>: <summary>`.
1114
+ def tool_finished(name, result: nil)
1115
+ return delegation_finished(result) if name == "task"
1116
+
1117
+ failed = result.respond_to?(:errorish?) ? result.errorish? : (result.respond_to?(:success?) && !result.success?)
1118
+ metric = if failed
1119
+ result&.respond_to?(:truncated_preview) ? result.truncated_preview : nil
1120
+ else
1121
+ (result.respond_to?(:metrics) && result.metrics) ||
1122
+ (result&.respond_to?(:truncated_preview) ? result.truncated_preview : nil)
1123
+ end
1124
+ activity_finished(name, metric: metric, failed: failed)
1125
+ status_back_to_thinking
1126
+ end
1127
+
1128
+ # After a tool's `└ ✓` close row commits, swap the status row back to the
1129
+ # thinking phase (the P4 inter-tool gap) with the accumulated stats. The
1130
+ # live row count is a simple per-turn UI tally — the footer's exact
1131
+ # ran/denied split from the Loop stays authoritative.
1132
+ def status_back_to_thinking
1133
+ return unless @turn_active
1134
+
1135
+ @turn_tool_count += 1
1136
+ return unless thinking_painter
1137
+
1138
+ @thinking_indicator = true
1139
+ status_show("thinking", phase: :thinking)
1140
+ end
1141
+
1142
+ def compression_started(at: nil)
1143
+ $stdout.puts
1144
+ $stdout.puts @pastel.dim("┄ compacting context… ┄")
1145
+ end
1146
+
1147
+ def compression_finished(metadata, at: nil)
1148
+ saved = metadata[:saved_tokens] || metadata["saved_tokens"] || 0
1149
+ $stdout.puts @pastel.dim("┄ compacted · saved #{saved} tok ┄")
1150
+ end
1151
+
1152
+ # Ctrl+O reveal: re-render the LAST retained reasoning buffer as the
1153
+ # full-style `┊` aside, committed into scrollback NOW (append-only — a
1154
+ # scrollback terminal can't un-print the committed cue, so this is a
1155
+ # one-way reveal of the retained buffer, not a hide-toggle). A no-op when
1156
+ # nothing is retained (hidden mode, or no reasoning yet this session).
1157
+ # Wired as the BottomComposer's on_ctrl_o callback; prints through $stdout
1158
+ # so it lands above the prompt under the composer's render mutex.
1159
+ def reveal_last_reasoning
1160
+ # NOTHING retained (hidden mode never buffered one, or — the common case
1161
+ # on providers that stream no thinking blocks at all — no reasoning ever
1162
+ # arrived): give the advertised key ONE dim line of feedback instead of
1163
+ # a forever-silent no-op that reads as a broken keybinding (#133). One
1164
+ # note per dry spell: further presses stay silent until reasoning is
1165
+ # actually retained (which resets the flag below).
1166
+ if @last_reasoning.nil? || @last_reasoning.strip.empty?
1167
+ unless @no_reasoning_note_shown
1168
+ @no_reasoning_note_shown = true
1169
+ note("no reasoning retained — this provider streamed no thinking blocks")
1170
+ end
1171
+ return
1172
+ end
1173
+
1174
+ # IDEMPOTENT + SILENT: a scrollback aside can't be un-printed, so
1175
+ # revealing the SAME retained buffer twice would just stack an identical
1176
+ # block. Once this thought has been revealed, any further Ctrl+O is a
1177
+ # true silent no-op — we print NOTHING (no ack line), so a human mashing
1178
+ # Ctrl+O gets silence, not growing scrollback. #collapse_reasoning clears
1179
+ # the flag when a NEW thought is retained, so its first reveal works, and
1180
+ # a new turn resets it so its first reveal works again.
1181
+ return if @last_reasoning_revealed
1182
+
1183
+ commit_reasoning_aside(@last_reasoning, @last_reasoning_seconds.to_i)
1184
+ @last_reasoning_revealed = true
1185
+ # Re-emit the idle prompt so the cursor returns to a proper prompt line
1186
+ # instead of being stranded on a bare line below the reveal. Guarded —
1187
+ # degrade silently if Reline isn't the active input (e.g. in-turn).
1188
+ redisplay_idle_prompt
1189
+ end
1190
+
1191
+ # Ask Reline to repaint its prompt + current buffer after out-of-band
1192
+ # output (the Ctrl+O reveal) has scrolled below the parked idle prompt.
1193
+ # Uses the public Reline line-refresh seam; fully guarded so a Reline that
1194
+ # lacks it (or a non-Reline input path) degrades to a no-op rather than
1195
+ # crashing the prompt. Does NOT attempt to move the reveal above the prompt
1196
+ # (that's the deferred pinned-layout work) — it only restores the prompt
1197
+ # line so the cursor isn't left bare.
1198
+ def redisplay_idle_prompt
1199
+ return unless defined?(Reline)
1200
+
1201
+ core = Reline.respond_to?(:core) ? Reline.core : nil
1202
+ line_editor = core&.instance_variable_get(:@line_editor)
1203
+ if line_editor.respond_to?(:rerender)
1204
+ line_editor.rerender
1205
+ elsif core.respond_to?(:line_editor) && core.line_editor.respond_to?(:rerender)
1206
+ core.line_editor.rerender
1207
+ end
1208
+ rescue StandardError
1209
+ nil
1210
+ end
1211
+
1212
+ # `/reasoning` with no arg: confirm the current render mode in house style.
1213
+ # ┄ reasoning: collapsed ┄
1214
+ def reasoning_status(mode)
1215
+ $stdout.puts
1216
+ $stdout.puts @pastel.dim("┄ reasoning: #{mode} ┄")
1217
+ end
1218
+
1219
+ # `/reasoning <mode>`: confirm the session render-mode switch. The actual
1220
+ # state change is written to config by the executor so the adapter gate
1221
+ # (which reads config) and this render path stay on one source of truth.
1222
+ # ┄ reasoning collapsed → full ┄
1223
+ # Switching to `hidden` gets an explanatory line instead of the terse arrow
1224
+ # — "hidden" is otherwise opaque (no cue, no aside), so we spell out what it
1225
+ # does and how to bring reasoning back.
1226
+ def reasoning_changed(mode, previous: nil)
1227
+ $stdout.puts
1228
+ if mode.to_sym == :hidden
1229
+ $stdout.puts @pastel.dim("┄ reasoning hidden — won't be shown (ctrl-o or /reasoning to bring it back) ┄")
1230
+ else
1231
+ arrow = previous && previous != mode ? "#{previous} → #{mode}" : mode.to_s
1232
+ $stdout.puts @pastel.dim("┄ reasoning #{arrow} ┄")
1233
+ end
1234
+ end
1235
+
1236
+ # `/think` with no arg: confirm the current effort in house style.
1237
+ # ┄ effort: medium ┄
1238
+ def think_status(effort)
1239
+ $stdout.puts
1240
+ $stdout.puts @pastel.dim("┄ effort: #{effort} ┄")
1241
+ end
1242
+
1243
+ # `/think <level>`: confirm the effort switch.
1244
+ # ┄ effort medium → high ┄
1245
+ def think_changed(effort, previous: nil)
1246
+ arrow = previous && previous != effort ? "#{previous} → #{effort}" : effort.to_s
1247
+ $stdout.puts
1248
+ $stdout.puts @pastel.dim("┄ effort #{arrow} ┄")
1249
+ end
1250
+
1251
+ def mode_changed(name, previous: nil)
1252
+ arrow = previous && previous != name ? "#{previous} → #{name}" : name.to_s
1253
+ text = "┄ mode #{arrow} ┄"
1254
+ $stdout.puts
1255
+ $stdout.puts(name.to_sym == :yolo ? @pastel.yellow(text) : @pastel.dim(text))
1256
+ end
1257
+
1258
+ # Short human labels for the post-turn inline jobs the status row tracks.
1259
+ JOB_STATUS_LABELS = {
1260
+ "ExtractMemoryJob" => "memory",
1261
+ "DistillSkillJob" => "skills",
1262
+ "SummarizeSessionJob" => "summary"
1263
+ }.freeze
1264
+
1265
+ def job_enqueued(type)
1266
+ puts_colored(:dim, " ⊕ Job enqueued: #{type}") if Rubino.configuration.ui_verbose?
1267
+ end
1268
+
1269
+ # Post-turn inline jobs (P6): the aux-LLM memory extract / skill distill
1270
+ # used to freeze the UI for seconds after the footer. The turn-scoped
1271
+ # status row is still alive here (it stops at #turn_finished, not at the
1272
+ # footer), so swap its label to "polishing · <job>" while each job runs.
1273
+ def job_started(type)
1274
+ puts_colored(:dim, " ▶ Job started: #{type}") if Rubino.configuration.ui_verbose?
1275
+ return unless @turn_active && thinking_painter
1276
+
1277
+ @thinking_indicator = true
1278
+ status_show("polishing", phase: :job, hint: job_status_label(type))
1279
+ end
1280
+
1281
+ def job_finished(type)
1282
+ puts_colored(:dim, " ■ Job finished: #{type}") if Rubino.configuration.ui_verbose?
1283
+ clear_thinking_indicator if @turn_active
1284
+ end
1285
+
1286
+ def job_status_label(type)
1287
+ JOB_STATUS_LABELS[type.to_s] || type.to_s
1288
+ end
1289
+
1290
+ def with_spinner(message, &block)
1291
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
1292
+ spinner.auto_spin
1293
+ result = block.call
1294
+ spinner.success
1295
+ result
1296
+ rescue StandardError => e
1297
+ spinner.error
1298
+ raise e
1299
+ end
1300
+
1301
+ # --- Legacy box methods (used by print_session_history replay) ---
1302
+
1303
+ def box_open(*pieces, at: nil, color: nil)
1304
+ # Compact: just print the activity name
1305
+ type = pieces.first.to_s
1306
+ activity_started(type)
1307
+ end
1308
+
1309
+ def box_close(*_pieces, color: nil)
1310
+ # Compact: close the activity
1311
+ activity_finished(@activity_name || "done", failed: color == :red)
1312
+ end
1313
+
1314
+ private
1315
+
1316
+ # True when a prior "always" decision covers this call — either the
1317
+ # exact (tool, args) scope or the tool-wide parent ("always this tool").
1318
+ def approval_cached?(scope)
1319
+ return false unless scope
1320
+
1321
+ @approval_cache.allowed?(@session_id, scope) ||
1322
+ @approval_cache.allowed?(@session_id, tool_scope(scope))
1323
+ end
1324
+
1325
+ # The tool-wide parent of a "<tool>:<command>" scope. "shell:ls" → "shell".
1326
+ # A scope without a command part is already tool-wide.
1327
+ def tool_scope(scope)
1328
+ scope.to_s.split(":", 2).first
1329
+ end
1330
+
1331
+ def remember(scope, decision)
1332
+ return unless scope
1333
+
1334
+ @approval_cache.remember(@session_id, scope, decision)
1335
+ end
1336
+
1337
+ # The rule this approval would be remembered/persisted as, derived from
1338
+ # the command (PrefixDeriver). Nil when there is no command (tool-wide /
1339
+ # structured-arg tools), so no prefix is offered and "always" persists
1340
+ # nothing. Mirrors UI::API#derive_rule.
1341
+ def derive_rule(tool, command, pattern_key)
1342
+ return nil if command.to_s.strip.empty?
1343
+
1344
+ Security::PrefixDeriver.rule_for(tool: tool.to_s, command: command.to_s, pattern_key: pattern_key)
1345
+ end
1346
+
1347
+ # Routes the chosen menu symbol to the matching cache/persister action,
1348
+ # mirroring UI::API#apply_decision so CLI and HTTP behave identically:
1349
+ # :once -> nothing
1350
+ # :deny_always -> persist a permissions:deny rule, then deny
1351
+ # :always_prefix -> session cache + persist the derived PREFIX rule
1352
+ # :always_command -> session cache + persist the NARROW rule
1353
+ # :always_tool -> CLI-only: remember the whole tool (in-memory only)
1354
+ # :no -> deny this call only (one-off, nothing remembered)
1355
+ # Returns the boolean approval result.
1356
+ def apply_choice(choice, scope:, command:, rule:)
1357
+ case choice
1358
+ when :once
1359
+ true
1360
+ when :deny_always
1361
+ persist_deny(scope, command, rule)
1362
+ false
1363
+ when :always_prefix
1364
+ remember(scope, "session")
1365
+ persist_rule(rule)
1366
+ true
1367
+ when :always_command
1368
+ remember(scope, "session")
1369
+ persist_rule(narrow_rule(command))
1370
+ true
1371
+ when :always_tool
1372
+ remember(tool_scope(scope), "always")
1373
+ true
1374
+ else
1375
+ false
1376
+ end
1377
+ end
1378
+
1379
+ # Persists a derived rule value to security.command_allowlist (append-
1380
+ # unique) so it pre-approves siblings across restarts. Skips when there is
1381
+ # no value to persist. Same path UI::API uses.
1382
+ def persist_rule(rule)
1383
+ Security::AllowlistPersister.persist(rule.value) if rule
1384
+ end
1385
+
1386
+ # Persists a permissions:deny rule for the "deny always" choice, scoped the
1387
+ # SAME way the allow side scopes (prefix when derivable, else exact command).
1388
+ # ApprovalPolicy#decide checks permissions:deny first, so this auto-denies
1389
+ # the pattern across restarts. The tool name comes from the scope key
1390
+ # ("<tool>:<command>"). No-op when there is no pattern to key on.
1391
+ def persist_deny(scope, command, rule)
1392
+ pattern = Security::DenyPersister.pattern_for(
1393
+ tool: tool_scope(scope), rule: rule, command: command
1394
+ )
1395
+ Security::DenyPersister.persist(pattern) if pattern
1396
+ end
1397
+
1398
+ # The narrow rule for :always_command — exact command, or the dangerous
1399
+ # pattern key when the command is dangerous (S3/S5 semantics).
1400
+ def narrow_rule(command)
1401
+ return nil if command.to_s.strip.empty?
1402
+
1403
+ Security::PrefixDeriver.narrow_rule_for(tool: "shell", command: command.to_s)
1404
+ end
1405
+
1406
+ # A DEDICATED TTY::Prompt for the approval menu whose output is wrapped
1407
+ # in IndentedIO, so the question + menu render in the SAME column as the
1408
+ # card's body (P7) instead of flush-left under a split card. Separate
1409
+ # from @prompt so #ask and other prompts keep their flush layout.
1410
+ def approval_prompt
1411
+ @approval_prompt ||= TTY::Prompt.new(output: IndentedIO.new)
1412
+ end
1413
+
1414
+ # Prompts for the approval choice. The menu is built from the derived
1415
+ # rule: an "always — allow `<prefix>` commands" item is offered only when
1416
+ # a :prefix rule is derivable (non-dangerous command). For a dangerous
1417
+ # command no prefix is offered (the pattern description is already shown);
1418
+ # only the narrow "always, this command" persists. Returns one of
1419
+ # :once, :always_prefix, :always_command, :always_tool, :no (deny this
1420
+ # call only), :deny_always (persist a permissions:deny rule).
1421
+ def approval_choice(rule = nil, tool: nil)
1422
+ prefix = rule&.kind == :prefix ? rule.value : nil
1423
+ # The narrow "always" scope reads in the TOOL's own terms: "this command"
1424
+ # is shell-flavored and is confusing on an `edit`/`write` card (which
1425
+ # shows file_path/old_string, not a command), so non-shell tools get
1426
+ # "this exact call" instead (#222). Shell keeps "command".
1427
+ narrow = scope_noun(tool)
1428
+ # Pause the bottom composer for the duration of the select so the menu
1429
+ # reads the real $stdin (no reader-thread race) and tty-screen sizes the
1430
+ # real $stdout (no NoMethodError on the StdoutProxy). No-op off-turn.
1431
+ BottomComposer.run_in_terminal do
1432
+ # Labels are grammatically parallel (#87): every line is an
1433
+ # "<Approve|Deny> — <scope>" verb phrase, so the affirmatives and
1434
+ # denies read symmetrically instead of mixing "yes, once" with
1435
+ # "no — deny this once".
1436
+ approval_prompt.select("approve?", cycle: false) do |menu|
1437
+ menu.choice "Approve once", :once
1438
+ menu.choice "Approve — `#{prefix}` commands (always)", :always_prefix if prefix
1439
+ menu.choice "Approve — #{narrow} (always)", :always_command
1440
+ menu.choice "Approve — this tool (this session)", :always_tool
1441
+ menu.choice "Deny once", :no
1442
+ menu.choice "Deny — #{narrow} (always)", :deny_always
1443
+ end
1444
+ end
1445
+ end
1446
+
1447
+ # The narrow-scope noun for the "always" approval rows, by tool kind: a
1448
+ # shell command is literally a "command"; every other tool (edit, write, …)
1449
+ # has no command, so the call itself is the scope (#222).
1450
+ def scope_noun(tool)
1451
+ tool.to_s == "shell" ? "this command" : "this exact call"
1452
+ end
1453
+
1454
+ # Head lines of tool output the transcript shows (P2). Resolved from
1455
+ # config on every call so /config changes apply mid-session.
1456
+ def tool_preview_limit
1457
+ Rubino.configuration.display_tool_output_preview_lines
1458
+ end
1459
+
1460
+ def reset_tool_preview
1461
+ @tool_preview_shown = 0
1462
+ @tool_preview_hidden = 0
1463
+ end
1464
+
1465
+ # The dim collapse marker: `… +N lines (full output → context)`.
1466
+ def hidden_lines_marker(hidden)
1467
+ "… +#{hidden} line#{"s" if hidden != 1} (full output → context)"
1468
+ end
1469
+
1470
+ # Commits the marker for streamed lines the preview budget swallowed
1471
+ # (#tool_chunk), right before the close row. Idempotent per tool run.
1472
+ def flush_tool_preview_overflow
1473
+ hidden = @tool_preview_hidden.to_i
1474
+ reset_tool_preview
1475
+ return unless hidden.positive?
1476
+
1477
+ $stdout.puts @pastel.dim(" #{hidden_lines_marker(hidden)}")
1478
+ end
1479
+
1480
+ # Renders body text with the current activity open.
1481
+ def write_body_lines(text, &style)
1482
+ text.each_line do |line|
1483
+ chomped = line.chomp
1484
+ rendered = style ? style.call(chomped) : chomped
1485
+ $stdout.puts " #{rendered}"
1486
+ end
1487
+ end
1488
+
1489
+ # Applies a style hash to a token string.
1490
+ def apply_style(text, style)
1491
+ return text if style.nil? || style.empty?
1492
+
1493
+ decorators = []
1494
+ modifiers = style[:modifiers] || []
1495
+ decorators << :bold if modifiers.include?(:bold)
1496
+ decorators << :italic if modifiers.include?(:italic)
1497
+ decorators << :underline if modifiers.include?(:underline)
1498
+
1499
+ fg = style[:fg]
1500
+ result = text
1501
+ decorators.each do |dec|
1502
+ result = @pastel.send(dec, result) if @pastel.valid?(dec)
1503
+ end
1504
+ # The MarkdownRenderer emits a few color names Pastel doesn't define
1505
+ # (e.g. :gray). Skip an unknown fg rather than raise — degrade to no
1506
+ # color so streamed markdown never crashes the turn.
1507
+ result = @pastel.send(fg, result) if fg && @pastel.valid?(fg)
1508
+ result
1509
+ end
1510
+
1511
+ # --- Streaming markdown (per-block render + commit) ---
1512
+
1513
+ # Streams one content chunk: feed the block buffer, render+commit every
1514
+ # block that just completed (markdown), and show the still-incomplete tail
1515
+ # RAW in the live region. The tail is shown raw on purpose — it gets
1516
+ # re-rendered + committed the moment its block closes (so a `**bold**` token
1517
+ # mid-stream shows raw for a beat, then snaps to styled once the block ends).
1518
+ def stream_content(text)
1519
+ @stream_md ||= StreamingMarkdown.new
1520
+ completed = @stream_md.feed(text)
1521
+ # On the plain path the previous raw tail sits on the current line with no
1522
+ # newline; clear it before committing finished blocks so a committed line
1523
+ # doesn't glue onto the leftover tail. (The #live seam replaces its own
1524
+ # transient row, so this is a no-op there.)
1525
+ clear_plain_tail if completed.any?
1526
+ completed.each { |block| commit_markdown_block(block) }
1527
+ # Live region: a small ROLLING window over the in-flight block — its last
1528
+ # few raw lines, so a long list/table block keeps its recent context
1529
+ # visible while it streams instead of vanishing to a single flickering
1530
+ # line until the whole block commits (#127). Bounded, so a long open
1531
+ # fence can never push the prompt off-screen; the block still snaps to
1532
+ # rendered markdown the moment it completes.
1533
+ show_live_tail(@stream_md.live_tail(LIVE_TAIL_ROWS))
1534
+ end
1535
+
1536
+ # Erases an in-place raw tail on the plain (no-#live) path before a commit.
1537
+ def clear_plain_tail
1538
+ return if $stdout.respond_to?(:live)
1539
+
1540
+ clear_line
1541
+ end
1542
+
1543
+ # Flush on stream end: render+commit the final block. If a fence is still
1544
+ # open (the model never sent the closing ```), the buffered text is emitted
1545
+ # as PLAIN lines so nothing is lost (markdown of a half-open fence would be
1546
+ # garbage). Always clears the live region.
1547
+ def flush_content_stream
1548
+ remaining = @stream_md.flush
1549
+ clear_plain_tail if remaining
1550
+ if remaining
1551
+ if open_fence?(remaining)
1552
+ remaining.split("\n", -1).each { |line| $stdout.puts "#{MD_MARGIN}#{line}" }
1553
+ else
1554
+ commit_markdown_block(remaining)
1555
+ end
1556
+ end
1557
+ show_live_tail("")
1558
+ end
1559
+
1560
+ # An odd number of fence lines means a ``` was opened but never closed.
1561
+ def open_fence?(text)
1562
+ text.to_s.lines.count { |l| l.match?(StreamingMarkdown::FENCE_RE) }.odd?
1563
+ end
1564
+
1565
+ # Shows the raw in-progress tail in the live region — #paint_live resolves
1566
+ # the seam (proxy #live / active composer row / CR repaint on a bare TTY /
1567
+ # skipped into a pipe). A blank tail just clears the transient row.
1568
+ # Nothing is lost on the skipped path — every block is still rendered +
1569
+ # committed in full when it completes.
1570
+ #
1571
+ # Each tail row carries the SAME MD_MARGIN the committed lines above it
1572
+ # get (#commit_markdown_block), so the raw in-flight lines sit in the
1573
+ # same column as the rendered block they snap into — a flush-left tail
1574
+ # under indented output read as a jarring seam. Off-TTY this is moot:
1575
+ # #paint_live skips pipes entirely (#56).
1576
+ def show_live_tail(tail)
1577
+ paint_live(margined_tail(tail))
1578
+ end
1579
+
1580
+ # WRAPS the in-flight tail to the terminal width and keeps the last
1581
+ # LIVE_TAIL_ROWS wrapped rows (P12): a long streamed paragraph used to
1582
+ # collapse into ONE head-truncated row ("…the very end of it") because
1583
+ # the raw tail was clamped per LINE, not wrapped. Each visible row
1584
+ # carries the SAME MD_MARGIN the committed lines above it get
1585
+ # (#commit_markdown_block), so the raw in-flight rows sit in the same
1586
+ # column as the rendered block they snap into. A blank tail passes
1587
+ # through untouched (it just clears the transient row).
1588
+ def margined_tail(tail)
1589
+ text = tail.to_s
1590
+ return text if text.empty?
1591
+
1592
+ budget = terminal_cols - MD_MARGIN.length - 1
1593
+ rows = text.split("\n", -1).flat_map { |line| wrap_tail_row(line, budget) }
1594
+ rows.last(LIVE_TAIL_ROWS).map { |row| "#{MD_MARGIN}#{row}" }.join("\n")
1595
+ end
1596
+
1597
+ # Splits one raw line into display-width-budgeted rows (wide glyphs are
1598
+ # never split across rows — same measurement the composer/live region
1599
+ # use). An empty line stays one empty row.
1600
+ def wrap_tail_row(line, budget)
1601
+ budget = 1 if budget < 1
1602
+ rows = [+""]
1603
+ width = 0
1604
+ line.each_char do |ch|
1605
+ w = LiveRegion.display_width(ch)
1606
+ if width + w > budget && !rows.last.empty?
1607
+ rows << +""
1608
+ width = 0
1609
+ end
1610
+ rows.last << ch
1611
+ width += w
1612
+ end
1613
+ rows
1614
+ end
1615
+
1616
+ # Commits any in-progress streaming so the next committed output (the
1617
+ # approval card, a note, etc.) starts on its own clean line. When a
1618
+ # content/thinking stream is open it runs the normal #stream_end (flush
1619
+ # the tail + clear the indicator); otherwise it just clears a lone
1620
+ # "thinking…" indicator. Idempotent: a no-op when nothing is live.
1621
+ def finalize_stream
1622
+ if @stream_type
1623
+ stream_end
1624
+ else
1625
+ clear_thinking_indicator
1626
+ end
1627
+ end
1628
+
1629
+ # Toggles the bottom composer's "answer content is actively streaming"
1630
+ # flag (D1). The composer gates the Ctrl+O reveal on it: a reveal requested
1631
+ # while true is deferred and flushed by #end_content_stream when the answer
1632
+ # finishes, so the `┊` aside never lands between answer chunks. A no-op when
1633
+ # no composer owns the screen (between turns / piped input / plain mode).
1634
+ # No respond_to?/blanket-rescue safety net here: the composer is our own
1635
+ # class, so a signature drift across this seam must fail LOUDLY in the
1636
+ # suite instead of silently un-gating the reveal (#62). Only terminal IO
1637
+ # errors are swallowed — end_content_stream can flush a deferred reveal
1638
+ # (real output), and a dying tty must not break the turn. Cosmetic.
1639
+ def mark_content_streaming(active)
1640
+ composer = BottomComposer.current
1641
+ return unless composer
1642
+
1643
+ active ? composer.begin_content_stream : composer.end_content_stream
1644
+ rescue IOError, Errno::EIO
1645
+ nil
1646
+ end
1647
+
1648
+ # Erases the transient status row through the same seam the frames used
1649
+ # (#paint_live): the proxy/composer transient row when one is active,
1650
+ # else an in-place CR + clear-line on a bare TTY. INSIDE a turn this only
1651
+ # HIDES the row (the turn-scoped engine thread keeps running so the next
1652
+ # event can swap the label back in); outside a turn it stops the engine
1653
+ # entirely — the old one-shot semantics (#58, #74).
1654
+ def clear_thinking_indicator
1655
+ return unless @thinking_indicator
1656
+
1657
+ if @turn_active
1658
+ status_hide
1659
+ else
1660
+ status_stop
1661
+ end
1662
+ @thinking_indicator = false
1663
+ end
1664
+
1665
+ # --- Turn-scoped status row engine (V3 "Ruby facet") ---
1666
+
1667
+ # Shows the row with +label+ (and optional +hint+), resetting the phase
1668
+ # clock. Starts the engine thread when none is running. No-op into a pipe
1669
+ # — there is nothing to animate and raw escapes must not leak (#56).
1670
+ def status_show(label, phase:, hint: nil)
1671
+ return unless thinking_painter
1672
+
1673
+ @status_mutex.synchronize do
1674
+ @turn_started_at ||= monotonic_now
1675
+ @status = { label: label, hint: hint, phase: phase,
1676
+ phase_started_at: monotonic_now, visible: true }
1677
+ start_status_thread
1678
+ end
1679
+ end
1680
+
1681
+ # Like #status_show, but keeps the current phase clock when the row is
1682
+ # already showing this exact label — so per-delta callers (the reasoning
1683
+ # stream) don't reset the elapsed counter ten times a second.
1684
+ def status_ensure(label, phase:, hint: nil)
1685
+ current = @status_mutex.synchronize { @status&.dup }
1686
+ return if current && current[:visible] && current[:label] == label && current[:hint] == hint
1687
+
1688
+ status_show(label, phase: phase, hint: hint)
1689
+ end
1690
+
1691
+ # Hides the row WITHOUT killing the engine thread (mid-turn: the live
1692
+ # answer tail takes the row over while text streams).
1693
+ def status_hide
1694
+ @status_mutex.synchronize do
1695
+ @status[:visible] = false if @status
1696
+ paint_live("")
1697
+ end
1698
+ $stdout.flush
1699
+ end
1700
+
1701
+ # Kills + joins the engine thread and clears the row. Idempotent. The
1702
+ # only exits: turn end, error, interrupt, or a stand-alone wait ending.
1703
+ def status_stop
1704
+ thread = @thinking_thread
1705
+ @thinking_thread = nil
1706
+ if thread
1707
+ thread.kill
1708
+ thread.join
1709
+ end
1710
+ @status_mutex.synchronize { @status = nil }
1711
+ @turn_started_at = nil unless @turn_active
1712
+ paint_live("")
1713
+ $stdout.flush
1714
+ rescue StandardError
1715
+ nil
1716
+ end
1717
+
1718
+ # The single ticker thread for the turn. Frames are built AND painted
1719
+ # under @status_mutex so a hide/relabel can never interleave with a
1720
+ # half-painted stale frame.
1721
+ def start_status_thread
1722
+ return if @thinking_thread&.alive?
1723
+
1724
+ @thinking_thread = Thread.new do
1725
+ i = 0
1726
+ loop do
1727
+ @status_mutex.synchronize do
1728
+ paint_live(status_frame(i)) if @status && @status[:visible]
1729
+ end
1730
+ i += 1
1731
+ sleep STATUS_TICK
1732
+ end
1733
+ rescue StandardError
1734
+ # The animation is cosmetic — a repaint failure must never break the
1735
+ # turn. Stop quietly.
1736
+ end
1737
+ end
1738
+
1739
+ # One frame: the sweeping red ◆ on its dim ┄ track, label + stats right.
1740
+ def status_frame(tick)
1741
+ pos = FACET_FRAMES[tick % FACET_FRAMES.length]
1742
+ track = (0...FACET_TRACK_CELLS).map do |cell|
1743
+ cell == pos ? @pastel.red("◆") : @pastel.dim("┄")
1744
+ end.join
1745
+ "#{track} #{@pastel.dim(status_text)}"
1746
+ end
1747
+
1748
+ # The text to the right of the track. Thinking phase: turn-elapsed +
1749
+ # accumulated stats (tools run, ~tok streamed); tool/job phases: the
1750
+ # label · hint · per-phase elapsed. Always fits 80 cols.
1751
+ def status_text(now = monotonic_now)
1752
+ s = @status
1753
+ parts = [s[:label]]
1754
+ parts << s[:hint] if s[:hint]
1755
+ if s[:phase] == :thinking
1756
+ parts << "#{(now - (@turn_started_at || s[:phase_started_at])).to_i}s"
1757
+ parts << "#{@turn_tool_count} tool#{"s" if @turn_tool_count != 1}" if @turn_tool_count.positive?
1758
+ parts << "~#{format_status_tokens(@turn_tok_chars / 4)} tok" if @turn_tok_chars >= 4
1759
+ parts << "enter to interrupt" if interrupt_hint?(s, now)
1760
+ else
1761
+ parts << "#{(now - s[:phase_started_at]).to_i}s"
1762
+ end
1763
+ text = parts.join(" · ")
1764
+ budget = [terminal_cols, 80].min - FACET_TRACK_CELLS - 2
1765
+ text.length > budget ? "#{text[0, budget - 1]}…" : text
1766
+ end
1767
+
1768
+ # Mid-turn token spend is an ESTIMATE from streamed deltas (~4 chars/tok)
1769
+ # — always marked with the leading ~; the exact total stays in the footer.
1770
+ def format_status_tokens(count)
1771
+ count >= 1000 ? "#{(count / 1000.0).round(1)}k" : count.to_s
1772
+ end
1773
+
1774
+ # The hint only appears where Enter actually interrupts (a composer owns
1775
+ # the keyboard) and only once the wait has dragged past the threshold.
1776
+ def interrupt_hint?(state, now)
1777
+ @turn_active &&
1778
+ (now - state[:phase_started_at]) >= INTERRUPT_HINT_AFTER &&
1779
+ !BottomComposer.current.nil?
1780
+ end
1781
+
1782
+ # Commits the buffered reasoning into scrollback per the active render mode,
1783
+ # then clears the animation. Called when the first answer token arrives, or
1784
+ # when a tool/activity starts with reasoning still buffered (never strand
1785
+ # the cue). After committing it retains the buffer in @last_reasoning so a
1786
+ # later ctrl-o can re-reveal it, and resets @reasoning_buffer for the next
1787
+ # phase. :hidden commits NOTHING but still retains the buffer, so a single
1788
+ # Ctrl+O can pull the last thought back on demand — exactly what the
1789
+ # hidden-mode ack promises (#76).
1790
+ def collapse_reasoning
1791
+ seconds = thinking_elapsed_seconds
1792
+ buffered = @reasoning_buffer
1793
+ mode = reasoning_mode
1794
+
1795
+ clear_thinking_indicator
1796
+
1797
+ unless buffered.strip.empty?
1798
+ if mode == :full
1799
+ commit_reasoning_aside(buffered, seconds)
1800
+ elsif mode == :collapsed
1801
+ commit_reasoning_cue(seconds)
1802
+ end
1803
+ @last_reasoning = buffered
1804
+ @last_reasoning_seconds = seconds
1805
+ # A new thought is retained — reset the reveal guard so the first
1806
+ # Ctrl+O on THIS thought re-emits its aside (Fix 1 idempotency), and
1807
+ # re-arm the "no reasoning retained" note (#133) for a later dry spell.
1808
+ @last_reasoning_revealed = false
1809
+ @no_reasoning_note_shown = false
1810
+ end
1811
+
1812
+ @reasoning_buffer = +""
1813
+ @thinking_started_at = nil
1814
+ end
1815
+
1816
+ # The dim one-liner committed in :collapsed mode:
1817
+ # ┄ ✻ thought for <N>s · ctrl-o to show ┄
1818
+ def commit_reasoning_cue(seconds)
1819
+ $stdout.puts @pastel.dim("┄ ✻ thought for #{seconds}s · ctrl-o to show ┄")
1820
+ end
1821
+
1822
+ # The expanded reasoning aside (full mode / ctrl-o reveal), reusing the
1823
+ # `┊` left-rail family of #probe_aside: a `┄ thinking ┄` opening rail, the
1824
+ # reasoning body on a dim 2-space `┊` rail, and a `┄ thought for <N>s ┄`
1825
+ # closing rail. The aside is already fully shown and is append-only
1826
+ # scrollback that can't be un-printed, so the close line carries NO toggle
1827
+ # hint — promising "ctrl-o to hide" would be a lie and "ctrl-o to show"
1828
+ # would be redundant. The collapsed one-liner cue (#commit_reasoning_cue)
1829
+ # is the only place that carries the "ctrl-o to show" affordance.
1830
+ def commit_reasoning_aside(text, seconds)
1831
+ $stdout.puts
1832
+ $stdout.puts @pastel.dim("┄ thinking ┄#{"─" * 50}")
1833
+ text.to_s.each_line do |line|
1834
+ $stdout.puts @pastel.dim("┊ #{line.chomp}")
1835
+ end
1836
+ $stdout.puts @pastel.dim("┄ thought for #{seconds}s ┄")
1837
+ $stdout.puts
1838
+ end
1839
+
1840
+ # --- Subagent delegation rows (the `task` tool) ---
1841
+
1842
+ # `● delegated → <subagent> <prompt-preview>`. Stashes the subagent name so
1843
+ # the matching #delegation_finished can label the close row even though
1844
+ # tool_finished only receives the result, not the arguments.
1845
+ def delegation_started(arguments)
1846
+ collapse_reasoning
1847
+ sub = delegation_field(arguments, :subagent) || "subagent"
1848
+ prompt = delegation_field(arguments, :prompt)
1849
+ @delegation_subagent = sub
1850
+ preview = prompt ? " #{truncate_inline(prompt, 60)}" : ""
1851
+ $stdout.puts unless %i[tool gap].include?(@last_block)
1852
+ $stdout.puts "#{@pastel.cyan("●")} #{@pastel.dim("delegated → #{sub}#{preview}")}"
1853
+ @activity_open = true
1854
+ @activity_name = "task"
1855
+ @last_block = :tool
1856
+ status_show("task", phase: :tool, hint: sub) if @turn_active
1857
+ end
1858
+
1859
+ # `✓ <subagent>: <summary>` (or `✗ <subagent>: <error>` on failure).
1860
+ #
1861
+ # The `task` tool reports its failures by RETURNING an error STRING
1862
+ # ("Error: unknown subagent …", "At capacity: …") — the executor then
1863
+ # wraps that in a SUCCESS-status Result, so #success? is true and the row
1864
+ # used to render a misleading green ✓ (#123, the B7 family on the
1865
+ # delegation card). Use the same #errorish? predicate #tool_finished
1866
+ # uses, plus the "At capacity:" prefix the task tool emits, so a failed
1867
+ # delegation renders the red ✗ variant — consistent with regular tools.
1868
+ def delegation_finished(result)
1869
+ @activity_open = false
1870
+ sub = @delegation_subagent || "subagent"
1871
+ output = (result.respond_to?(:output) ? result.output : result).to_s
1872
+ if !delegation_failed?(result) && (m = SPAWN_HANDLE_RE.match(output))
1873
+ # Background spawn: ONE lifecycle grammar (P6) — the live-card row
1874
+ # shape, dim, no green ✓ (nothing finished yet; it only started).
1875
+ $stdout.puts @pastel.dim(" └ ▸ #{m[2]} · #{m[1]} · started")
1876
+ else
1877
+ summary = truncate_inline(output.strip, 80)
1878
+ icon, color =
1879
+ if delegation_failed?(result) then ["✗", :red]
1880
+ elsif delegation_noop?(result) then ["⊘", :dim]
1881
+ else ["✓", :dim] # quiet close — color only on failure (P1)
1882
+ end
1883
+ $stdout.puts @pastel.public_send(color, " └ #{icon} #{sub}: #{summary}")
1884
+ end
1885
+ @delegation_subagent = nil
1886
+ @last_block = :tool
1887
+ status_back_to_thinking
1888
+ end
1889
+
1890
+ # True when a delegation did nothing / was denied: the subagent produced no
1891
+ # final text, so the task tool returned the no-op placeholder. Not a failure
1892
+ # (no error), but not a success either — it renders a neutral ⊘ instead of a
1893
+ # misleading green ✓ (#16).
1894
+ def delegation_noop?(result)
1895
+ output = result.respond_to?(:output) ? result.output : result
1896
+ Tools::TaskTool.noop_result?(output)
1897
+ end
1898
+
1899
+ # True when a delegation result represents a failure. Mirrors how
1900
+ # #tool_finished decides (Result#errorish? — non-success status, an
1901
+ # error_code, or an "Error:" output), and additionally treats the task
1902
+ # tool's "At capacity:" string (a success-status Result that #errorish?
1903
+ # does not catch) as a failure so the row shows ✗.
1904
+ def delegation_failed?(result)
1905
+ return false if result.nil?
1906
+
1907
+ base = result.respond_to?(:errorish?) ? result.errorish? : (result.respond_to?(:success?) && !result.success?)
1908
+ return true if base
1909
+
1910
+ output = result.respond_to?(:output) ? result.output : result
1911
+ output.to_s.lstrip.start_with?("At capacity:")
1912
+ end
1913
+
1914
+ def delegation_field(arguments, key)
1915
+ return nil unless arguments.is_a?(Hash)
1916
+
1917
+ value = arguments[key] || arguments[key.to_s]
1918
+ v = value.to_s.strip
1919
+ v.empty? ? nil : v
1920
+ end
1921
+
1922
+ # Collapses a possibly-multiline text into ONE inline segment: lines are
1923
+ # joined with " — " (instead of dropping everything after the first), then
1924
+ # clamped to +max+ chars. Keeps multi-line tool metrics / subagent
1925
+ # summaries on a single styled row.
1926
+ def truncate_inline(text, max)
1927
+ inline = text.to_s.lines.map(&:strip).reject(&:empty?).join(" — ")
1928
+ inline.length > max ? "#{inline[0, max - 1]}…" : inline
1929
+ end
1930
+
1931
+ # Short identifier piece for the tool header.
1932
+ def args_hint(arguments)
1933
+ return nil unless arguments.is_a?(Hash)
1934
+
1935
+ raw_key, raw_value = pick_hint(arguments)
1936
+ return nil unless raw_value
1937
+
1938
+ hint = Util::SecretsMask.mask_value(raw_value, key: raw_key).to_s
1939
+ first = hint.lines.first.to_s.strip
1940
+ label = first.length > 60 ? "#{first[0, 57]}..." : first
1941
+
1942
+ if path_key?(raw_key)
1943
+ Util::Hyperlink.wrap_path(first, label: label)
1944
+ else
1945
+ label
1946
+ end
1947
+ end
1948
+
1949
+ # A PLAIN short hint for the status row (no OSC-8 hyperlink wrapping —
1950
+ # the live row is repainted 10×/s and must stay measurable plain text).
1951
+ def status_hint(arguments)
1952
+ return nil unless arguments.is_a?(Hash)
1953
+
1954
+ raw_key, raw_value = pick_hint(arguments)
1955
+ return nil unless raw_value
1956
+
1957
+ first = Util::SecretsMask.mask_value(raw_value, key: raw_key).to_s.lines.first.to_s.strip
1958
+ first.length > 30 ? "#{first[0, 29]}…" : first
1959
+ end
1960
+
1961
+ def path_key?(key)
1962
+ k = key.to_s
1963
+ %w[file_path path].include?(k)
1964
+ end
1965
+
1966
+ def pick_hint(arguments)
1967
+ %i[pattern file_path path command].each do |k|
1968
+ v = arguments[k] || arguments[k.to_s]
1969
+ return [k, v] if v && !v.to_s.empty?
1970
+ end
1971
+ nil
1972
+ end
1973
+
1974
+ def color_for(role)
1975
+ case role
1976
+ when :info then :cyan
1977
+ when :success then :green
1978
+ when :warning then :yellow
1979
+ when :error then :red
1980
+ when :status then :dim
1981
+ when :tool then :cyan
1982
+ when :muted then :dim
1983
+ end
1984
+ end
1985
+ end
1986
+ end
1987
+ end