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,1599 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Rubino
6
+ module UI
7
+ # A persistent, VISIBLE, editable input line pinned at the bottom of the
8
+ # terminal while agent output streams ABOVE it and scrolls into native
9
+ # scrollback. No alternate screen, no mouse tracking — trackpad/wheel scroll
10
+ # and text selection keep working like a normal shell.
11
+ #
12
+ # This is the Ruby equivalent of prompt_toolkit's +patch_stdout+ /
13
+ # +run_in_terminal+: every write that should land above the prompt goes
14
+ # through {#print_above}, which erases the input line, emits the output (it
15
+ # scrolls up), then redraws the input from the preserved buffer. A render
16
+ # +Mutex+ makes each erase→print→redraw an atomic frame so the streaming
17
+ # writer and the keystroke handler never interleave a half-frame.
18
+ #
19
+ # Responsibilities:
20
+ # * own the editable +@buffer+ and draw it ({#draw_input})
21
+ # * funnel all turn output through {#print_above} so it never clobbers the
22
+ # input line (the {StdoutProxy} swaps +$stdout+ for the turn so the ~30
23
+ # existing +$stdout.print/puts+ call sites need zero changes)
24
+ # * run a raw, char-by-char keystroke loop in a thread that echoes typed
25
+ # chars and pushes completed lines into the shared
26
+ # {Interaction::InputQueue} the steering logic already consumes
27
+ #
28
+ # Four collaborators carry the cohesive sub-jobs behind narrow seams, with
29
+ # the composer as the facade that owns the render mutex and the public API:
30
+ # {EscapeReader} (escape-sequence byte reading/parsing → semantic actions),
31
+ # {CompletionMenu} (the /command + @file dropdown state machine + rows),
32
+ # {QueuedIndicators} (the "⏳ queued:" stack + rows) and {LiveRegion} (the
33
+ # erase→commit→redraw frame discipline + width math). {StatusBar} formats
34
+ # the model/context line the composer pins BELOW the input (see below).
35
+ #
36
+ # The INPUT BLOCK is multi-row: a buffer longer than the terminal width
37
+ # WRAPS and the input grows downward as the user types (like Claude Code),
38
+ # up to +max_input_rows+ visual rows; past the cap it scrolls vertically,
39
+ # keeping the caret row in view. A multi-line PASTE keeps its REAL newlines
40
+ # in the buffer and the submitted payload (#57) and each newline now renders
41
+ # as a REAL row break in the editing view. ↑/↓ move by visual row while the
42
+ # caret is inside a multi-row buffer and fall back to history navigation on
43
+ # the first/last row (the readline/Claude Code convention). Below the input
44
+ # block an optional dim STATUS BAR shows the model id + context saturation;
45
+ # it is the live region's LAST row, redrawn with every frame and omitted on
46
+ # narrow (< MIN_STATUS_COLS) terminals.
47
+ #
48
+ # (Two earlier MVP limitations no longer apply: arrows/Home/End/Delete/
49
+ # word-jump now drive the cursor via #consume_escape_sequence, and the
50
+ # draw/wrap/clamp paths all measure by DISPLAY width — a wide CJK/emoji
51
+ # glyph counts as two columns — so fullwidth lines wrap at the right
52
+ # column instead of "slightly early".)
53
+ class BottomComposer
54
+ PROMPT = "❯ "
55
+ ANSI_RE = /\e\[[0-9;]*m/
56
+
57
+ # Hard ceiling on the subagent card block (rows ABOVE the partial + prompt).
58
+ # The registry caps live children at MAX_CONCURRENT (3) and the formatter
59
+ # adds an overflow + hint line, so 5 rows covers the worst case while
60
+ # guaranteeing the live region can never grow unbounded and push the prompt
61
+ # off-screen — a corrupt caller is clamped, not trusted.
62
+ MAX_CARD_ROWS = 6
63
+
64
+ # Hard ceiling on the live partial rows so a runaway caller can never push
65
+ # the prompt off-screen (mirrors MAX_CARD_ROWS for the card block).
66
+ MAX_PARTIAL_ROWS = 4
67
+
68
+ # Default cap on the input block's visual rows (config:
69
+ # display.input_max_rows, threaded in by the chat command). Past it the
70
+ # block scrolls vertically, keeping the caret row in view, so a huge
71
+ # paste can never push the live region off-screen.
72
+ MAX_INPUT_ROWS = 8
73
+
74
+ # The status bar is omitted on terminals narrower than this — at that
75
+ # width the truncated line carries no information worth a row.
76
+ MIN_STATUS_COLS = 40
77
+
78
+ # QUEUED-message prefix: submitting a line that starts with this queues the
79
+ # REST instead of interrupting — the discoverable, terminal-independent
80
+ # fallback for Alt+Enter (which some terminals don't deliver).
81
+ QUEUED_PREFIX = "/queued "
82
+
83
+ # Double-Esc window (seconds): two LONE Esc presses within this at the
84
+ # IDLE prompt fire the +on_double_esc+ hook (the Esc-Esc rewind picker —
85
+ # the Claude Code muscle-memory chord). Tight enough that a deliberate
86
+ # single Esc (menu dismiss) followed by an unrelated Esc later never
87
+ # reads as a chord.
88
+ DOUBLE_ESC_SECONDS = 0.4
89
+
90
+ # Bracketed paste (DEC 2004): the terminal wraps pasted text in
91
+ # ESC[200~ … ESC[201~ so we can tell a PASTE from typed keystrokes and
92
+ # keep each embedded \n from submitting a half-line (L1 — "pasteline2"
93
+ # glue). The body is inserted as ONE editable string with its REAL
94
+ # newlines preserved (#57, see #submit_paste); each renders as a real
95
+ # row break in the multi-row input block. We enable it on start, disable
96
+ # on stop/suspend; the {EscapeReader} accumulates the body between the
97
+ # markers.
98
+ PASTE_ON = "\e[?2004h"
99
+ PASTE_OFF = "\e[?2004l"
100
+
101
+ # @param input_queue [Interaction::InputQueue] completed lines are pushed
102
+ # here; the agent loop / REPL drain it (steering). Required for the
103
+ # reader to do anything useful.
104
+ # @param input [IO] keystroke source (default $stdin).
105
+ # @param output [IO] where the prompt + above-output is written
106
+ # (default $stdout).
107
+ # @param prompt [String] the input-line prefix after the rail — the
108
+ # plain "❯ " caret (may contain ANSI color). Defaults to the bare
109
+ # caret for standalone use / tests. The mode/skill chip that used to
110
+ # ride here lives in the STATUS BAR now (the Rail rubino redesign).
111
+ # @param rail [String, nil] the one-column brand rail (the red "▍")
112
+ # drawn as the FIRST column of EVERY input row — the first row AND
113
+ # each wrapped/newline continuation — so a multi-row draft reads as
114
+ # one block. May carry ANSI color. nil/empty ⇒ no rail (standalone /
115
+ # tests / the cooked fallback), with the exact pre-rail geometry.
116
+ # The rail is pure input-block chrome: committed echoes
117
+ # ("<prompt><line>") never carry it, so scrollback stays clean.
118
+ # @param on_ctrl_o [#call, nil] invoked when the user presses Ctrl+O — the
119
+ # CLI uses it to REVEAL the last retained reasoning buffer as a `┊` aside
120
+ # committed into scrollback. The composer never formats reasoning itself;
121
+ # it only dispatches the keystroke. nil = no-op.
122
+ # @param on_mode_cycle [#call, nil] invoked when the user presses Shift+Tab
123
+ # to cycle the mode. The callback owns the mode logic (persist + emit the
124
+ # transition footer) and RETURNS the freshly-built STATUS-BAR line (the
125
+ # mode token leads it), which the composer adopts and redraws — the mode
126
+ # lives in the status bar now, not in a prompt chip. nil return ⇒ no
127
+ # status change (e.g. the yolo arm toast). The composer holds no mode
128
+ # knowledge itself. nil = Shift+Tab is a no-op.
129
+ # @param echo [Symbol] how a submitted line is echoed into scrollback:
130
+ # :queued (default) is the IN-TURN composer — Enter INTERRUPTS the active
131
+ # turn and sends the line as the next turn (the default), so it never
132
+ # commits an echo here (the next turn's prompt echo is committed by the
133
+ # chat loop when it runs); :prompt prints the prompt + the line (e.g.
134
+ # "default ❯ <line>") — the idle case, where the line IS the user's
135
+ # message and should read back like a normal shell submit.
136
+ # @param on_interrupt [#call, nil] invoked when the user presses Enter to
137
+ # submit a line WHILE a turn is active. The chat loop wires this to the
138
+ # active turn's cancel so the current turn is interrupted and the
139
+ # just-submitted line runs as the next turn immediately. nil ⇒ no
140
+ # interrupt (the line is simply queued, as before).
141
+ # @param pending_queued [Array<String>, nil] shared stack of messages the
142
+ # user EXPLICITLY queued (Alt+Enter / "/queued <msg>") while a turn is
143
+ # active. Rendered as "⏳ queued: <msg>" rows ABOVE the input (live region,
144
+ # never committed). Shared across the per-turn composers by the chat loop
145
+ # so the indicator survives a composer teardown and is removed/committed as
146
+ # a normal message when the queued item's turn runs. nil ⇒ a private list
147
+ # (standalone / tests).
148
+ # @param status_line [String, nil] the styled model/context line pinned
149
+ # BELOW the input row (see {StatusBar}). nil/empty ⇒ no bar. Updated
150
+ # at turn boundaries via {#set_status} — never per-delta.
151
+ # @param max_input_rows [Integer, nil] cap on the input block's visual
152
+ # rows (config display.input_max_rows); nil ⇒ MAX_INPUT_ROWS.
153
+ # @param paste_store [UI::PasteStore, nil] the per-session paste store
154
+ # behind the file-backed paste pipeline: a large paste collapses to a
155
+ # "[Pasted text #N +M lines]" placeholder registered here (expanded to
156
+ # the full body at the chat loop's message-build seam), and backspace
157
+ # on a placeholder deletes it WHOLE. Shared across the per-turn
158
+ # composers by the chat command, like +pending_queued+. nil ⇒ every
159
+ # paste inlines into the buffer (standalone / tests), as before.
160
+ # @param on_double_esc [#call, nil] invoked when the user presses Esc
161
+ # twice within {DOUBLE_ESC_SECONDS} at the IDLE prompt — the Esc-Esc
162
+ # rewind chord. Wired only on the IDLE composer (the chat loop opens
163
+ # the rewind picker from it); the in-turn composer leaves it nil, so
164
+ # Esc keeps no double-tap meaning during a turn. With a menu open the
165
+ # first Esc keeps its dismiss meaning AND arms the chord, so Esc-Esc
166
+ # over a menu reads dismiss-then-rewind. The hook runs on the reader
167
+ # thread — callers must only flip a flag, never block or take the
168
+ # composer's locks (the idle loop drains it, like the Ctrl+C trap).
169
+ def initialize(input_queue:, input: $stdin, output: $stdout, prompt: PROMPT,
170
+ rail: nil, on_ctrl_o: nil, on_mode_cycle: nil,
171
+ completion_source: nil, history: nil, echo: :queued,
172
+ on_interrupt: nil, pending_queued: nil,
173
+ status_line: nil, max_input_rows: nil, paste_store: nil,
174
+ on_double_esc: nil)
175
+ @input_queue = input_queue
176
+ @input = input
177
+ @output = output
178
+ @on_ctrl_o = on_ctrl_o
179
+ @on_mode_cycle = on_mode_cycle
180
+ @on_double_esc = on_double_esc
181
+ # Monotonic time of the last LONE Esc (nil when unarmed) — the
182
+ # double-tap window the Esc-Esc rewind chord measures against.
183
+ @last_esc_at = nil
184
+ @echo = echo
185
+ @on_interrupt = on_interrupt
186
+ # Per-session paste store (file-backed paste pipeline). nil ⇒ inline
187
+ # pastes, the exact legacy behavior.
188
+ @paste_store = paste_store
189
+ # Shared (or private) stack of EXPLICITLY-queued messages, rendered as
190
+ # "⏳ queued: <msg>" rows above the input while pending.
191
+ @queued = QueuedIndicators.new(pending_queued || [])
192
+ # Shared completion discovery (slash commands + @file picker) extracted
193
+ # from LineInput. nil ⇒ the `/`+`@` completion menu is inert (steering /
194
+ # standalone use), so the composer degrades to a plain editor. Kept for
195
+ # the token highlight; the dropdown itself lives in the CompletionMenu.
196
+ @completion = completion_source
197
+ # History ring, backed by Reline::HISTORY by default for continuity with
198
+ # the old idle prompt. nil keeps a private ring (tests / standalone).
199
+ @history = history || InputHistory.new
200
+ # The /command + @file dropdown: open/refine/accept/dismiss state and
201
+ # the rendered rows (see CompletionMenu). Inert without a source.
202
+ @menu = CompletionMenu.new(completion_source)
203
+ # Escape-sequence reader: consumes the byte tail of an ESC keystroke
204
+ # from @input and returns the semantic action (see EscapeReader). The
205
+ # callable indirection keeps it on the composer's CURRENT input.
206
+ @escapes = EscapeReader.new(-> { @input })
207
+ @prompt = prompt.to_s.empty? ? PROMPT : prompt
208
+ # The brand rail (red "▍"): the first column of EVERY input row.
209
+ # Empty ⇒ railless, the exact legacy geometry.
210
+ @rail = (rail || "").to_s
211
+ # Visible widths ignore ANSI color escapes so the wrap math is
212
+ # correct for a colored rail/prompt. @prefix_width is the column the
213
+ # input text starts in on EVERY row (rail + prompt on the first,
214
+ # rail + hanging indent on continuations) — all caret/wrap math
215
+ # anchors to it.
216
+ @rail_width = @rail.gsub(ANSI_RE, "").length
217
+ @prompt_width = @prompt.gsub(ANSI_RE, "").length
218
+ @prefix_width = @rail_width + @prompt_width
219
+ @buffer = +""
220
+ # Insertion point, measured in CHARACTERS (codepoints) into @buffer.
221
+ # Always in 0..@buffer.length; the terminal cursor is parked here on
222
+ # every redraw. Replaces the old append-only model.
223
+ @cursor = 0
224
+ @partial = +"" # live, un-committed streamed line shown above the prompt
225
+ # TRANSIENT announcement row (e.g. the Shift+Tab mode confirmation):
226
+ # rendered in the live region directly above the partial/prompt, redrawn
227
+ # in place every frame and NEVER committed to scrollback. Cleared on the
228
+ # next keystroke so it reads as a one-shot toast, not stacking scrollback
229
+ # (D3). Empty ⇒ no row.
230
+ @announce = +""
231
+ # True only while the model's ANSWER content is actively streaming (set by
232
+ # the CLI's stream/stream_end lifecycle, NOT the thinking phase — commits
233
+ # during thinking land cleanly above the partial). Gates the Ctrl+O reveal
234
+ # so it never bisects a streaming answer (D1).
235
+ @content_streaming = false
236
+ # True for the WHOLE turn — from the moment the chat loop hands a prompt to
237
+ # the runner until the turn fully unwinds — including the THINKING phase
238
+ # that precedes the first content token. Set/cleared by the chat loop's
239
+ # run_turn bracket (#begin_turn / #end_turn). A "queued ▸" type-ahead echo
240
+ # is deferred whenever a turn is active (thinking OR content streaming), not
241
+ # only when content is streaming: a line submitted while the model is still
242
+ # THINKING would otherwise commit its echo ABOVE the thought line and the
243
+ # whole answer (D7e). nil/false ⇒ idle, immediate echo as before.
244
+ @turn_active = false
245
+ # A reveal (Ctrl+O) requested WHILE content was streaming, queued to flush
246
+ # once the stream ends so the `┊` aside renders cleanly AFTER the answer
247
+ # instead of between chunks (D1). nil ⇒ nothing deferred.
248
+ @deferred_reveal = false
249
+ # Subagent CARD block (Variant A): zero or more collapsed live rows shown
250
+ # ABOVE the streamed partial and the prompt, redrawn in place each frame.
251
+ # Driven by UI::CLI#set_subagent_cards from the BackgroundTasks registry.
252
+ @cards = []
253
+ # The live-region renderer: owns the count of rows currently drawn ABOVE
254
+ # the prompt and the scroll-safe erase→commit→redraw frame discipline
255
+ # (see LiveRegion).
256
+ @region = LiveRegion.new(output)
257
+ # The dim status line pinned BELOW the input block (model + context
258
+ # saturation). Drawn as the live region's LAST row on every frame;
259
+ # empty ⇒ no bar (one fewer row). Updated via #set_status at turn
260
+ # boundaries only — it rides the existing redraws, never repaints on
261
+ # its own per stream delta.
262
+ @status = (status_line || "").to_s
263
+ # Input-block geometry: the visual-row cap and the vertical scroll
264
+ # offset (top visible layout row) once the buffer outgrows the cap.
265
+ @max_input_rows = positive_int(max_input_rows) || MAX_INPUT_ROWS
266
+ @input_scroll = 0
267
+ @render = Mutex.new
268
+ @reader = nil
269
+ @stop_pipe = nil # self-pipe write end used to wake the reader's select
270
+ @running = false
271
+ @suspended = false
272
+ @saved_stdout = nil
273
+ @cols = compute_cols
274
+ end
275
+
276
+ # True only when both ends are real TTYs. Off this path the composer is a
277
+ # no-op and the caller falls back to the plain (cooked, no-proxy) flow —
278
+ # piped / -q / server input must not touch terminal modes.
279
+ def self.active?(input: $stdin, output: $stdout)
280
+ input.tty? && output.tty?
281
+ rescue StandardError
282
+ false
283
+ end
284
+
285
+ # The composer running the CURRENT turn, if any. Set on #start, cleared on
286
+ # #stop, so {run_in_terminal} can find it without threading it through every
287
+ # call site. One chat process drives one turn at a time, so a single
288
+ # class-level slot is the right granularity.
289
+ class << self
290
+ attr_accessor :current
291
+ end
292
+
293
+ # Run +block+ with the REAL terminal restored — the Ruby equivalent of
294
+ # prompt_toolkit's +run_in_terminal+. When a composer owns the screen for
295
+ # the current turn, PAUSE it (stop the raw reader, restore $stdout to the
296
+ # real IO, leave cooked mode, clear the prompt rows) for the duration of the
297
+ # block, then RESUME it (re-enter raw mode, restart the reader, redraw the
298
+ # preserved buffer). With no active composer it just yields. This is what
299
+ # lets a mid-turn TTY::Prompt (approval / ask) read the real $stdin and let
300
+ # tty-screen probe the real $stdout's size, instead of crashing on the
301
+ # write-only StdoutProxy or racing the reader thread for $stdin.
302
+ def self.run_in_terminal
303
+ composer = current
304
+ return yield unless composer
305
+
306
+ composer.suspend
307
+ begin
308
+ yield
309
+ ensure
310
+ composer.resume
311
+ end
312
+ end
313
+
314
+ # Starts the keystroke reader thread and draws the initial prompt. Installs
315
+ # a SIGWINCH handler that recomputes the width and redraws under the mutex.
316
+ # Returns self.
317
+ def start
318
+ return self if @running
319
+
320
+ @running = true
321
+ self.class.current = self
322
+ install_winch_trap
323
+ @render.synchronize do
324
+ # Leave a blank row above the first prompt so the first above-output
325
+ # doesn't glue onto whatever the REPL just printed.
326
+ @output.print(PASTE_ON)
327
+ @output.print("\r\n")
328
+ draw_input
329
+ end
330
+ @reader = start_reader
331
+ self
332
+ end
333
+
334
+ # Stops the reader thread, restores cooked mode, and leaves the cursor on a
335
+ # fresh line so the next REPL prompt isn't glued to the input line. Safe to
336
+ # call multiple times. Restores the previous SIGWINCH handler.
337
+ def stop
338
+ return unless @running
339
+
340
+ @running = false
341
+ self.class.current = nil if self.class.current.equal?(self)
342
+ stop_reader
343
+ restore_winch_trap
344
+ # Raw mode must never leak past the turn, even if the block-form restore
345
+ # was interrupted. Best-effort.
346
+ @input.cooked! if tty?
347
+ @render.synchronize { clear_live_region_to_clean_line }
348
+ rescue IOError, Errno::ENOTTY, Errno::EIO
349
+ nil
350
+ end
351
+
352
+ # PAUSE the composer so an interactive prompt can own the real terminal
353
+ # (see {run_in_terminal}). Stops the raw reader and leaves cooked mode so
354
+ # TTY::Prompt can read $stdin uncontended, restores the REAL $stdout (the
355
+ # composer's @output — built BEFORE the StdoutProxy swap) so tty-screen
356
+ # probes the real terminal, and clears the prompt rows. The typed @buffer
357
+ # draft is preserved for #resume. Idempotent: a no-op once already
358
+ # suspended (or never started).
359
+ def suspend
360
+ return unless @running && !@suspended
361
+
362
+ @suspended = true
363
+ @saved_stdout = $stdout
364
+ $stdout = @output
365
+ stop_reader
366
+ restore_winch_trap
367
+ @input.cooked! if tty?
368
+ @render.synchronize { clear_live_region_to_clean_line }
369
+ rescue IOError, Errno::ENOTTY, Errno::EIO
370
+ nil
371
+ end
372
+
373
+ # RESUME after {suspend}: restore the StdoutProxy, re-enter raw mode,
374
+ # restart the reader, and redraw the input line from the preserved buffer.
375
+ def resume
376
+ return unless @suspended
377
+
378
+ @suspended = false
379
+ $stdout = @saved_stdout if @saved_stdout
380
+ @saved_stdout = nil
381
+ install_winch_trap
382
+ @render.synchronize do
383
+ @output.print(PASTE_ON)
384
+ draw_input
385
+ end
386
+ @reader = start_reader
387
+ self
388
+ rescue IOError, Errno::ENOTTY, Errno::EIO
389
+ nil
390
+ end
391
+
392
+ # Commits one block of output ABOVE the input line — it scrolls up into
393
+ # native scrollback — then redraws the prompt. This is THE coordinator
394
+ # every finished above-the-prompt write goes through (StdoutProxy routes
395
+ # committed lines here). +str+ may contain embedded newlines; each line is
396
+ # emitted with a trailing "\r\n" because OPOST is off in raw mode (a bare
397
+ # "\n" would not return the carriage and the next line would stair-step).
398
+ # Any live streamed partial is cleared first so it doesn't duplicate.
399
+ # A nil +str+ just repaints the prompt; an EMPTY string commits one
400
+ # deliberate blank row (the P3 rhythm gaps — see LiveRegion#commit).
401
+ def print_above(str)
402
+ @render.synchronize do
403
+ @partial = +""
404
+ render_frame(committed: str)
405
+ end
406
+ end
407
+
408
+ # Renders a LIVE, un-committed streamed line on the row directly above the
409
+ # prompt, redrawn in place as it grows (it does NOT scroll). Used by the
410
+ # StdoutProxy for partial stream tokens that have no newline yet, so the
411
+ # in-progress line appears live and grows in place — like prompt_toolkit
412
+ # batching a partial line. {#print_above} (a committed line) clears it.
413
+ def set_partial(str)
414
+ # While SUSPENDED (run_in_terminal: an approval/ask owns the real
415
+ # terminal) a live repaint here would draw the partial + prompt rows
416
+ # straight over the interactive prompt. Drop the frame — the next
417
+ # #resume redraws the region and the ticker's next frame lands normally.
418
+ return if @suspended
419
+
420
+ @render.synchronize do
421
+ @partial = (str || "").to_s
422
+ render_frame(committed: nil)
423
+ end
424
+ end
425
+
426
+ # Sets the SUBAGENT CARD block — a small list of collapsed live rows shown
427
+ # above the streamed partial and the prompt (Variant A). Each frame redraws
428
+ # them in place from this list, so concurrent background subagents appear as
429
+ # a calm stack of one-liners that update without scrolling. An empty/nil
430
+ # list clears the block. Redraws under the same render mutex every other
431
+ # live write uses, so a card update from the parent never interleaves a
432
+ # half-frame with a streamed token or a keystroke. The list is clamped to a
433
+ # sane bound by the caller (UI::SubagentCards), but we also cap it here so a
434
+ # buggy caller can never grow the live region past the screen.
435
+ def set_cards(lines)
436
+ # While SUSPENDED (run_in_terminal: an approval/ask owns the real
437
+ # terminal) a card repaint here would draw straight over the
438
+ # interactive prompt and can abort its blocked TTY read (#144). Drop
439
+ # the frame, like #set_partial — the cards converge from the registry
440
+ # snapshot on the next repaint after #resume.
441
+ return if @suspended
442
+
443
+ capped = Array(lines).first(MAX_CARD_ROWS)
444
+ @render.synchronize do
445
+ @cards = capped
446
+ render_frame(committed: nil)
447
+ end
448
+ end
449
+
450
+ # Remove the FIRST pending "⏳ queued:" indicator matching +msg+ (public:
451
+ # the chat loop calls this when the queued item's turn starts, so the
452
+ # indicator disappears from above the input as the item is committed as a
453
+ # normal message). Operates on the shared pending list, so it works from
454
+ # whichever composer is current. Returns true if one was removed.
455
+ def commit_queued(msg)
456
+ removed = false
457
+ @render.synchronize do
458
+ removed = !@queued.remove(msg).nil?
459
+ redraw if removed
460
+ end
461
+ removed
462
+ end
463
+
464
+ # True when a live partial line is currently shown above the prompt.
465
+ def partial?
466
+ !@partial.empty?
467
+ end
468
+
469
+ # True while the model's ANSWER content is actively streaming. The CLI's
470
+ # stream lifecycle toggles this (begin/end below); the keystroke handler
471
+ # reads it to defer the Ctrl+O reveal so it never bisects the answer (D1).
472
+ def streaming?
473
+ @content_streaming
474
+ end
475
+
476
+ # Marks the start of an ACTIVE content stream (called by the CLI when the
477
+ # first answer token arrives). The thinking phase does NOT set this, so a
478
+ # footer/aside that commits during thinking still lands cleanly above.
479
+ def begin_content_stream
480
+ @content_streaming = true
481
+ end
482
+
483
+ # Marks the end of the content stream (CLI stream_end / finalize). Flushes
484
+ # the Ctrl+O reveal (`┊` aside) deferred during the stream so it renders
485
+ # AFTER the finished answer block instead of between its chunks — the reveal
486
+ # belongs to the JUST-finished answer, so it lands right after the contiguous
487
+ # answer and BEFORE the turn-summary footer (D1). The "queued ▸" type-ahead
488
+ # echoes are NOT flushed here: they belong to the NEXT input the user lined
489
+ # up, so they flush at TURN END (#end_turn), after the footer, so the order
490
+ # reads answer → reveal → `↳ turn` footer → `queued ▸` echo(es) (D7a-c).
491
+ def end_content_stream
492
+ @content_streaming = false
493
+ return unless @deferred_reveal
494
+
495
+ @deferred_reveal = false
496
+ @on_ctrl_o&.call
497
+ end
498
+
499
+ # Marks the START of a turn — the chat loop's run_turn calls this when it
500
+ # hands a prompt to the runner. From here through #end_turn the composer is
501
+ # "in a turn" (the THINKING phase AND the content stream), so a "queued ▸"
502
+ # type-ahead echo is deferred for the WHOLE turn, not only while content
503
+ # streams (D7e). Idempotent.
504
+ def begin_turn
505
+ @turn_active = true
506
+ end
507
+
508
+ # Marks the END of a turn — the chat loop's run_turn `ensure` calls this
509
+ # AFTER the runner has fully unwound (so the turn-summary footer is already
510
+ # in scrollback). Idempotent. (The "queued ▸" deferred-echo flush that used
511
+ # to live here is retired: in the interrupt-by-default model a mid-turn
512
+ # Enter interrupts and runs next, and an explicit queue shows a live
513
+ # "⏳ queued:" indicator instead of a post-footer echo.)
514
+ def end_turn
515
+ @turn_active = false
516
+ end
517
+
518
+ # Sets the TRANSIENT announcement row (the Shift+Tab mode confirmation).
519
+ # It renders in the live region above the prompt and is redrawn in place —
520
+ # cycling N times REPLACES it, never stacks — and is cleared on the next
521
+ # keystroke, so it leaves ZERO committed scrollback lines (D2/D3). An
522
+ # empty/nil string clears it. Must NOT be routed through print_above.
523
+ def announce(text)
524
+ @render.synchronize do
525
+ @announce = (text || "").to_s
526
+ redraw
527
+ end
528
+ end
529
+
530
+ # Updates the status bar pinned below the input (model + context
531
+ # saturation — see {StatusBar}) and repaints in place. Called at TURN
532
+ # BOUNDARIES only (after the footer / on session resume), never per
533
+ # stream delta, so the bar can't busy-repaint. nil/empty clears the bar
534
+ # (its row disappears on the next frame). Dropped while suspended, like
535
+ # every other live repaint — the next #resume redraws.
536
+ def set_status(text)
537
+ return if @suspended
538
+
539
+ @render.synchronize do
540
+ @status = (text || "").to_s
541
+ redraw
542
+ end
543
+ end
544
+
545
+ # Handle a Ctrl+C pressed at the IDLE prompt (BH-2). Mirrors the industry
546
+ # norm (Claude Code / Codex / readline) and the during-turn double-tap so a
547
+ # single Ctrl+C never silently discards a typed draft:
548
+ #
549
+ # * buffer NON-EMPTY → CLEAR the line (and any open completion menu) and
550
+ # stay (returns :cleared). The draft-clear resets the exit timer, so a
551
+ # subsequent empty Ctrl+C starts the two-tap exit fresh.
552
+ # * buffer EMPTY, first tap → show a transient "(press Ctrl+C again to
553
+ # exit)" hint and stay (returns :hint).
554
+ # * buffer EMPTY, second tap within +window+ seconds → exit (returns
555
+ # :exit); the caller ends the session.
556
+ #
557
+ # Called by the idle reader OUTSIDE trap context (the SIGINT trap only flips
558
+ # a flag — Mutex#lock is forbidden in a trap), so the render mutex is safe
559
+ # here. +window+ is the double-tap window in seconds (the chat loop passes
560
+ # its DOUBLE_TAP_SECONDS so idle and in-turn behave identically).
561
+ def idle_interrupt(window: 2.0)
562
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
563
+
564
+ unless @buffer.empty?
565
+ @last_idle_int_at = nil
566
+ @render.synchronize do
567
+ @menu.close!
568
+ @buffer.clear
569
+ @cursor = 0
570
+ @announce = +""
571
+ redraw
572
+ end
573
+ return :cleared
574
+ end
575
+
576
+ return :exit if @last_idle_int_at && (now - @last_idle_int_at) <= window
577
+
578
+ @last_idle_int_at = now
579
+ announce("(press Ctrl+C again to exit)")
580
+ :hint
581
+ end
582
+
583
+ # Replaces the editable buffer with +text+ — MULTILINE-SAFE: real
584
+ # newlines stay in the buffer and render as real row breaks, exactly
585
+ # like a bracketed paste — parking the caret at the end, ready to edit.
586
+ # Used by the Esc-Esc rewind to pre-fill the picked message for
587
+ # edit-and-resend. Any open completion menu is closed (the text is a
588
+ # finished message, not a token being typed; typing afterwards reopens
589
+ # it via the normal auto-update) and history navigation resets so a
590
+ # fresh ↑ starts from the newest entry. nil/empty clears the buffer.
591
+ def prefill(text)
592
+ @render.synchronize do
593
+ @menu.close!
594
+ @buffer.replace(text.to_s)
595
+ @cursor = @buffer.length
596
+ @history.reset!
597
+ redraw
598
+ end
599
+ end
600
+
601
+ # The card rows currently shown (test/inspection helper).
602
+ attr_reader :cards
603
+
604
+ # The REAL terminal IO captured before the StdoutProxy swap. UI::Notifier
605
+ # rings the attention bell here while a turn owns the screen — BEL never
606
+ # moves the cursor, so it can't disturb the pinned input block.
607
+ attr_reader :output
608
+
609
+ # True when the /command + @file completion menu is open (inspection
610
+ # helper; the reader/specs check it to branch Tab/Enter/Esc handling).
611
+ def menu_open?
612
+ @menu.open?
613
+ end
614
+
615
+ # Redraws the INPUT BLOCK — the wrapped buffer rows plus the status bar —
616
+ # and parks the terminal cursor at the insertion point (@cursor). The
617
+ # buffer WRAPS at the terminal width (a real newline forces a row break),
618
+ # growing the block downward up to @max_input_rows visual rows; past the
619
+ # cap a vertical window keeps the caret row in view. The block manages
620
+ # its own erase: the previous frame's rows (recorded in the LiveRegion as
621
+ # input geometry) are cleared first, so a shrinking buffer never leaves
622
+ # stale rows, and the cheap keystroke path stays correct without a full
623
+ # live-region frame. All caret repositioning happens AFTER the last byte
624
+ # is printed, so a natural scroll while the block grows at the bottom of
625
+ # the screen can never desync the relative moves. Must be called under
626
+ # @render (callers below already hold it).
627
+ def draw_input
628
+ rows, caret_row, caret_col = visible_input_rows
629
+ status = status_row
630
+
631
+ @region.clear_input_block
632
+ rows.each_with_index do |row, i|
633
+ @output.print("\r\e[2K#{row}")
634
+ @output.print("\r\n") if i < rows.length - 1 || status
635
+ end
636
+ @output.print("\r\e[2K#{status}") if status
637
+
638
+ below = (rows.length - 1 - caret_row) + (status ? 1 : 0)
639
+ park_caret(rows, caret_col, below)
640
+ @region.input_drawn(above: caret_row, below: below)
641
+ @output.flush
642
+ end
643
+
644
+ # Park the terminal cursor at the caret after the block is fully printed
645
+ # (relative moves are only safe once nothing else will scroll): walk up
646
+ # past the rows below the caret row, re-home, and step right to the
647
+ # caret column. Skipped entirely when printing already left the cursor
648
+ # there — the caret at the end of a frame's last row, the common typing
649
+ # case — so those frames end with the buffer text, byte-minimal.
650
+ def park_caret(rows, caret_col, below)
651
+ return if below.zero? && caret_col == display_width(rows.last.gsub(ANSI_RE, ""))
652
+
653
+ @output.print("\e[#{below}A") if below.positive?
654
+ @output.print("\r")
655
+ @output.print("\e[#{caret_col}C") if caret_col.positive?
656
+ end
657
+
658
+ # The current editable buffer (test/inspection helper).
659
+ attr_reader :buffer
660
+
661
+ # Lays out @buffer into wrapped VISUAL rows at the current width.
662
+ # Returns [rows, caret_row, caret_col] where each row is
663
+ # { chars:, start:, prompt: } — its codepoints, the buffer index of its
664
+ # first char, and whether it carries the prompt prefix (only the first) —
665
+ # and caret_row/caret_col locate the insertion point (col in DISPLAY
666
+ # columns from the screen's left edge, so the caret column is comparable
667
+ # across rows for ↑/↓ navigation). A real "\n" forces a row break; a char
668
+ # that would overflow the per-row budget wraps whole (wide glyphs are
669
+ # never split across rows). The caret is placed where the NEXT typed char
670
+ # will land.
671
+ #
672
+ # Continuation rows (wrap or "\n") carry a HANGING INDENT of the prefix
673
+ # width (P12): every row's text starts in the same column as the first
674
+ # row's — after the rail + prompt — instead of dropping flush-left to
675
+ # column 0. The indent is pure layout (rail + spaces on render, width
676
+ # here) — never buffer content.
677
+ def layout_input
678
+ budget = row_budget
679
+ rows = [{ chars: [], start: 0, prompt: true }]
680
+ width = @prefix_width
681
+
682
+ @buffer.each_char.with_index do |ch, i|
683
+ if ch == "\n"
684
+ rows << { chars: [], start: i + 1, prompt: false }
685
+ width = @prefix_width
686
+ next
687
+ end
688
+ w = display_width(ch)
689
+ if width + w > budget
690
+ rows << { chars: [], start: i, prompt: false }
691
+ width = @prefix_width
692
+ end
693
+ rows.last[:chars] << ch
694
+ width += w
695
+ end
696
+ [rows, *caret_position(rows)]
697
+ end
698
+
699
+ # The caret's [visual_row, display_col] within a layout. The owning row
700
+ # is the LAST one starting at-or-before @cursor: a caret exactly on a
701
+ # WRAP boundary therefore lands on the wrapped row (where the next char
702
+ # will print), while a caret on a "\n" stays at the END of the broken
703
+ # row (the next row starts one past the newline) — the readline feel.
704
+ def caret_position(rows)
705
+ idx = rows.rindex { |r| @cursor >= r[:start] } || 0
706
+ row = rows[idx]
707
+ # Every row's text hangs at the prefix width (P12), so the caret
708
+ # column starts there on continuation rows too.
709
+ col = @prefix_width
710
+ row[:chars].each_with_index do |ch, j|
711
+ break if row[:start] + j >= @cursor
712
+
713
+ col += display_width(ch)
714
+ end
715
+ [idx, col]
716
+ end
717
+
718
+ # The display columns available per input row: one short of the width so
719
+ # a glyph in the final column never arms the terminal's deferred
720
+ # auto-wrap (the same rule LiveRegion#emit_row applies). Guarded so a
721
+ # degenerate narrow terminal still fits at least one char after the
722
+ # prompt instead of looping.
723
+ def row_budget
724
+ [@cols - 1, @prefix_width + 1].max
725
+ end
726
+
727
+ # The PRINTED input rows for this frame plus the caret position within
728
+ # them: the layout, windowed to @max_input_rows when the buffer outgrows
729
+ # the cap (the window follows the caret row minimally, like a scrolling
730
+ # viewport), each row rendered to its final string (prompt prefix +
731
+ # token highlight on a single-row buffer; plain continuation rows).
732
+ def visible_input_rows
733
+ rows, caret_row, caret_col = layout_input
734
+
735
+ if rows.length > @max_input_rows
736
+ top = @input_scroll.clamp(0, rows.length - @max_input_rows)
737
+ top = caret_row if caret_row < top
738
+ top = caret_row - @max_input_rows + 1 if caret_row > top + @max_input_rows - 1
739
+ @input_scroll = top
740
+ rows = rows[top, @max_input_rows]
741
+ caret_row -= top
742
+ else
743
+ @input_scroll = 0
744
+ end
745
+
746
+ single = rows.length == 1 && rows.first[:prompt]
747
+ # The rail leads EVERY row; continuations hang-indent under the text
748
+ # start (P12), so the indent fills the prompt columns after the rail.
749
+ indent = "#{@rail}#{" " * @prompt_width}"
750
+ texts = rows.map do |row|
751
+ body = row[:chars].join
752
+ if row[:prompt]
753
+ "#{@rail}#{@prompt}#{single ? highlight_line(body) : body}"
754
+ else
755
+ # Hanging indent (P12): continuations align under the text start.
756
+ "#{indent}#{body}"
757
+ end
758
+ end
759
+ [texts, caret_row, caret_col]
760
+ end
761
+
762
+ # The status-bar row for this frame, or nil when there is no bar: the
763
+ # status text is empty, the terminal is too narrow to be useful, or the
764
+ # styled line wouldn't fit the row (omit whole rather than truncate
765
+ # mid-ANSI — a cut escape sequence would leak attributes into the
766
+ # terminal).
767
+ def status_row
768
+ return nil if @status.empty? || @cols < MIN_STATUS_COLS
769
+ return nil if display_width(@status.gsub(ANSI_RE, "")) > @cols - 1
770
+
771
+ @status
772
+ end
773
+
774
+ # Feeds a single character through the edit logic. Public so the PTY/unit
775
+ # tests can drive editing without a live raw read. Returns :submit when the
776
+ # key committed a line, :quit on EOF/empty-Ctrl+D, otherwise nil.
777
+ #
778
+ # The buffer is edited at @cursor (a codepoint index), so insert/delete and
779
+ # the arrow/Home/End/word-jump moves all act mid-line, not just at the end.
780
+ def handle_key(ch)
781
+ # The transient mode announcement is a one-shot toast: any keystroke
782
+ # clears it (a fresh Shift+Tab re-sets it below via #cycle_mode). It lives
783
+ # only in the live region, so this never touches scrollback (D2/D3).
784
+ clear_announce
785
+ case ch
786
+ when nil
787
+ return :quit
788
+ when "\r", "\n"
789
+ # Enter while a completion menu is open ACCEPTS the highlighted
790
+ # candidate rather than submitting (matches the old Reline dropdown) —
791
+ # UNLESS the buffer is ALREADY an exact, complete command, in which
792
+ # case Enter SUBMITS it directly instead of splicing a trailing space
793
+ # and requiring a second Enter (D5).
794
+ if menu_open? && !@menu.exact_command?(@buffer)
795
+ accept_completion
796
+ return nil
797
+ end
798
+ submit_line
799
+ return :submit
800
+ when "\t" # Tab: accept the menu selection, or open the menu if a token is typed.
801
+ handle_tab
802
+ when "", "\b" # DEL / Backspace: delete the char BEFORE the cursor.
803
+ delete_back
804
+ when "\x04" # Ctrl+D: delete forward; on an empty buffer it's EOF/quit.
805
+ return :quit if @buffer.empty?
806
+
807
+ delete_forward
808
+ when "\x01" then move_to(0) # Ctrl+A → line start
809
+ when "\x05" then move_to(@buffer.length) # Ctrl+E → line end
810
+ when "\x02" then move_by(-1) # Ctrl+B → left
811
+ when "\x06" then move_by(1) # Ctrl+F → right
812
+ when "\x0b" then kill_to_end # Ctrl+K → delete to end of line
813
+ when "\x15" then kill_to_start # Ctrl+U → delete to start of line
814
+ when "\x0f" # Ctrl+O: reveal the last retained reasoning aside.
815
+ request_reveal
816
+ when "\e"
817
+ # ESC: start of a CSI/SS3 escape (arrows, Home/End, word-jump,
818
+ # Shift+Tab, bracketed paste) OR a lone ESC that dismisses the menu.
819
+ consume_escape_sequence
820
+ else
821
+ insert(ch) if printable?(ch)
822
+ # Other control bytes (incl. \x03 Ctrl+C, which the kernel turns into
823
+ # SIGINT before it reaches here under raw(intr: true)) are ignored.
824
+ end
825
+ nil
826
+ end
827
+
828
+ # Recomputes width from the terminal and redraws under the mutex. Public so
829
+ # the SIGWINCH handler (trap-context) and tests can call it.
830
+ #
831
+ # Redraws the WHOLE live region (the in-progress streamed @partial AND the
832
+ # prompt), not just the prompt: on resize xterm reflows/clears the bottom
833
+ # rows, so repainting only the prompt left the live streaming line blank
834
+ # until the turn committed (X1). Repainting the partial at the new width
835
+ # keeps mid-stream output visible across a resize. Committed scrollback is
836
+ # untouched (the terminal reflows it natively).
837
+ def resize
838
+ @render.synchronize do
839
+ @cols = compute_cols
840
+ # Repaint the FULL live region (cards + menu + partial + prompt) when
841
+ # anything above the prompt is live, reusing the same atomic frame the
842
+ # streaming writer uses; a bare draw_input would repaint only the
843
+ # prompt and leave the reflowed partial/card rows blank until the turn
844
+ # committed (X1). With nothing live above the prompt the cheap
845
+ # prompt-only redraw is enough. Same gate as every other repaint
846
+ # (#redraw → #live_region?), so the two paths can never drift again.
847
+ redraw
848
+ end
849
+ rescue StandardError
850
+ nil
851
+ end
852
+
853
+ private
854
+
855
+ # Draws one atomic frame via the {LiveRegion}. Layout (top → bottom):
856
+ #
857
+ # [committed lines] ← only when +committed+ is given; scroll into
858
+ # scrollback and stay there
859
+ # [live rows] ← cards, completion menu, transient announce,
860
+ # "⏳ queued:" indicators, streamed partial —
861
+ # redrawn in place every frame (do NOT scroll)
862
+ # [input block] ← "▍❯ " + buffer (the rail leads every row),
863
+ # wrapped over up to @max_input_rows visual
864
+ # rows; the cursor parks at the caret's
865
+ # row/column
866
+ # [status bar] ← the dim model + context line (when set/fits)
867
+ #
868
+ # The +@buffer+ is redrawn on every frame, so it can never be lost across
869
+ # a scroll. Must be called while holding @render.
870
+ def render_frame(committed:)
871
+ @region.frame(committed: committed, rows: live_rows, cols: @cols) { draw_input }
872
+ end
873
+
874
+ # The live rows for this frame, top → bottom: the subagent cards; the
875
+ # completion menu (a navigable list redrawn in place each frame, so it
876
+ # never scrolls or smears); the TRANSIENT announcement (mode confirmation
877
+ # — one row, never committed, D2/D3); the EXPLICITLY-queued "⏳ queued:"
878
+ # indicators (removed, and the item committed as a normal message, when
879
+ # its turn runs); and the streamed partial (one row per line, capped, so
880
+ # a rolling markdown tail can't push the prompt off-screen, #127).
881
+ def live_rows
882
+ rows = @cards.dup
883
+ rows.concat(menu_rows)
884
+ rows << @announce unless @announce.empty?
885
+ rows.concat(@queued.rows)
886
+ rows.concat(partial_rows)
887
+ rows
888
+ end
889
+
890
+ # The rendered completion-menu rows at the current width (also a spec
891
+ # inspection seam).
892
+ def menu_rows
893
+ @menu.rows(@cols)
894
+ end
895
+
896
+ # The partial as drawn: its last MAX_PARTIAL_ROWS lines, one row each.
897
+ def partial_rows
898
+ return [] if @partial.empty?
899
+
900
+ @partial.split("\n").last(MAX_PARTIAL_ROWS) || []
901
+ end
902
+
903
+ # Width math delegators (see LiveRegion for the display-column semantics):
904
+ # the draw/wrap paths here measure with the SAME rules the live-row
905
+ # clamp uses, so the input-block model can never disagree with the renderer.
906
+ def clamp(str, cols) = LiveRegion.clamp(str, cols)
907
+ def display_width(str) = LiveRegion.display_width(str)
908
+
909
+ # Enter. Captures + clears the buffer, then routes per the interrupt-by-
910
+ # default model:
911
+ # * empty → nothing.
912
+ # * "/queued <msg>" → QUEUE the rest (no interrupt), like Alt+Enter.
913
+ # * :prompt (idle) → immediate "<prompt><line>" echo (unchanged).
914
+ # * :queued + turn active → INTERRUPT the current turn and run the line
915
+ # next (default). The line is pushed; the next
916
+ # turn's prompt echo is committed by the chat
917
+ # loop when it runs, so nothing is echoed here.
918
+ # * :queued + idle → immediate "queued ▸ <line>" (standalone/tests
919
+ # with no turn and no interrupt hook).
920
+ def submit_line
921
+ line = take_buffer
922
+ return if line.strip.empty?
923
+
924
+ if line.start_with?(QUEUED_PREFIX)
925
+ msg = line[QUEUED_PREFIX.length..].to_s.strip
926
+ queue_message(msg) unless msg.empty?
927
+ return
928
+ end
929
+
930
+ @history.remember(line)
931
+
932
+ if @echo == :prompt
933
+ @input_queue&.push(line)
934
+ print_above("#{@prompt}#{line}")
935
+ elsif (@turn_active || @content_streaming) && @on_interrupt
936
+ # Interrupt-by-default: send the line as the NEXT turn immediately and
937
+ # interrupt the current one. Push to the FRONT so it runs ahead of any
938
+ # items the user explicitly parked (Alt+Enter / "/queued") earlier in
939
+ # this turn, THEN fire the interrupt. No echo here — run_turn commits
940
+ # the next turn's "<prompt><line>" when it runs — but the line DOES get
941
+ # a live "⏳ queued:" indicator while parked (#129): if the interrupted
942
+ # turn doesn't unwind instantly (e.g. it is deep in post-turn work),
943
+ # the submit must never be invisible. The indicator is removed at
944
+ # dequeue time like any other queued item.
945
+ queue_message(line, front: true)
946
+ fire_interrupt(line)
947
+ else
948
+ # No active turn (or no interrupt hook wired): a plain queued submit,
949
+ # echoed immediately as before.
950
+ @input_queue&.push(line)
951
+ print_above("queued ▸ #{line}")
952
+ end
953
+ end
954
+
955
+ # Fire the on_interrupt hook for a mid-turn submit. A SLASH COMMAND
956
+ # entered while nothing is visibly in flight (no content stream, no live
957
+ # partial row — e.g. the turn is only repainting a subagent card) is a
958
+ # QUIET interrupt (#111): the hook receives quiet=true so the chat loop
959
+ # can suppress the `⎿ interrupted` marker, which would otherwise strand
960
+ # a stray artifact above the command's own output even though the turn
961
+ # LOOKED idle. A hook that takes no parameter (tests/embedders) keeps
962
+ # the old no-arg contract.
963
+ def fire_interrupt(line)
964
+ if @on_interrupt.arity.zero?
965
+ @on_interrupt.call
966
+ else
967
+ quiet = line.start_with?("/") && !@content_streaming && @partial.empty?
968
+ @on_interrupt.call(quiet)
969
+ end
970
+ end
971
+
972
+ # Alt+Enter (\e\r / \e\n) — or the "/queued" alias — QUEUES the current
973
+ # buffer WITHOUT interrupting the active turn: push it to the input queue
974
+ # and add a live "⏳ queued: <msg>" row above the input. The current turn
975
+ # keeps running; the queued item is committed as a normal message + the
976
+ # indicator removed when its turn actually runs (the chat loop drives that
977
+ # via #commit_queued at dequeue time).
978
+ #
979
+ # With NO turn active there is nothing to queue behind: Alt+Enter behaves
980
+ # exactly like plain Enter (#130), so an idle chord can never park the
981
+ # message under a "⏳ queued:" indicator that no turn boundary will drain.
982
+ def queue_alt_enter
983
+ return submit_line unless @turn_active || @content_streaming
984
+
985
+ msg = take_buffer.strip
986
+ return if msg.empty?
987
+
988
+ @history.remember(msg)
989
+ queue_message(msg)
990
+ end
991
+
992
+ # Snapshot + clear the editable buffer under the render mutex, closing any
993
+ # open completion menu and repainting. Shared by Enter and Alt+Enter.
994
+ def take_buffer
995
+ line = nil
996
+ @render.synchronize do
997
+ @menu.close!
998
+ line = @buffer.dup
999
+ @buffer.clear
1000
+ @cursor = 0
1001
+ redraw # clears any open-menu rows above the prompt on submit
1002
+ end
1003
+ line
1004
+ end
1005
+
1006
+ # Push +msg+ to the input queue and show its live "⏳ queued:" indicator.
1007
+ # +front+ jumps the queue (the interrupt-by-default Enter): the message is
1008
+ # the NEXT one dequeued, and its indicator leads the pending rows so the
1009
+ # visible order matches the run order (#129).
1010
+ def queue_message(msg, front: false)
1011
+ front ? @input_queue&.push_front(msg) : @input_queue&.push(msg)
1012
+ @render.synchronize do
1013
+ @queued.push(msg, front: front)
1014
+ redraw
1015
+ end
1016
+ end
1017
+
1018
+ # Redraw the prompt, repainting the FULL live region (cards + menu +
1019
+ # partial) when anything lives above the prompt, else just the prompt row.
1020
+ # Must be called under @render. This is what lets the completion menu —
1021
+ # which renders ABOVE the prompt — appear/clear/track as it changes, the
1022
+ # same way the streamed partial and the subagent cards do.
1023
+ def redraw
1024
+ live_region? ? render_frame(committed: nil) : draw_input
1025
+ end
1026
+
1027
+ # True when ANYTHING lives above the prompt — rows already on screen from
1028
+ # the previous frame, or state that will draw rows this frame. The ONE
1029
+ # gate every repaint path shares (#redraw and #resize), extracted after
1030
+ # the two drifted apart (one omitted the open menu) into a latent render
1031
+ # bug (#62).
1032
+ def live_region?
1033
+ @region.live? || @menu.open? || @cards.any? || !@partial.empty? ||
1034
+ !@announce.empty? || @queued.any?
1035
+ end
1036
+
1037
+ # --- Cursor-aware editing primitives -------------------------------------
1038
+ # All mutate @buffer at @cursor (a codepoint index, 0..length) under the
1039
+ # render mutex and redraw. The completion menu is auto-opened/updated/closed
1040
+ # after any buffer change (see #auto_update_menu) so it tracks the typed
1041
+ # token the way the old Reline autocompletion did — typing a leading `/` or
1042
+ # `@` opens it with no Tab needed; history navigation is reset on any direct
1043
+ # edit so a fresh ↑ starts from the newest entry.
1044
+
1045
+ # Insert printable text at the cursor (typed char or single-line paste).
1046
+ def insert(str)
1047
+ @render.synchronize do
1048
+ chars = @buffer.chars
1049
+ chars.insert(@cursor, *str.chars)
1050
+ @buffer.replace(chars.join)
1051
+ @cursor += str.chars.length
1052
+ @history.reset!
1053
+ auto_update_menu
1054
+ redraw
1055
+ end
1056
+ end
1057
+
1058
+ # Backspace: remove the char before the cursor — or, when that char is
1059
+ # inside a registered "[Pasted text #N …]" placeholder, remove the WHOLE
1060
+ # token (a half-eaten placeholder would neither read nor expand). Only
1061
+ # store-registered spans get the whole-token treatment; lookalike text
1062
+ # the user typed deletes char-by-char as usual.
1063
+ def delete_back
1064
+ @render.synchronize do
1065
+ if @cursor.positive?
1066
+ chars = @buffer.chars
1067
+ if (span = @paste_store&.placeholder_span(@buffer, @cursor))
1068
+ chars.slice!(span[0], span[1])
1069
+ @cursor = span[0]
1070
+ else
1071
+ chars.delete_at(@cursor - 1)
1072
+ @cursor -= 1
1073
+ end
1074
+ @buffer.replace(chars.join)
1075
+ end
1076
+ @history.reset!
1077
+ auto_update_menu
1078
+ redraw
1079
+ end
1080
+ end
1081
+
1082
+ # Delete-forward (Ctrl+D / the Delete key): remove the char AT the cursor.
1083
+ def delete_forward
1084
+ @render.synchronize do
1085
+ chars = @buffer.chars
1086
+ if @cursor < chars.length
1087
+ chars.delete_at(@cursor)
1088
+ @buffer.replace(chars.join)
1089
+ end
1090
+ @history.reset!
1091
+ auto_update_menu
1092
+ redraw
1093
+ end
1094
+ end
1095
+
1096
+ # Delete from the cursor to the end of the line (Ctrl+K).
1097
+ def kill_to_end
1098
+ @render.synchronize do
1099
+ @buffer.replace(@buffer.chars.first(@cursor).join)
1100
+ @history.reset!
1101
+ auto_update_menu
1102
+ redraw
1103
+ end
1104
+ end
1105
+
1106
+ # Delete from the start of the line to the cursor (Ctrl+U).
1107
+ def kill_to_start
1108
+ @render.synchronize do
1109
+ @buffer.replace(@buffer.chars.drop(@cursor).join)
1110
+ @cursor = 0
1111
+ @history.reset!
1112
+ auto_update_menu
1113
+ redraw
1114
+ end
1115
+ end
1116
+
1117
+ # Move the cursor by +delta+ codepoints, clamped to the buffer.
1118
+ def move_by(delta)
1119
+ @render.synchronize do
1120
+ @cursor = (@cursor + delta).clamp(0, @buffer.length)
1121
+ auto_update_menu # moving off the token closes the menu
1122
+ redraw
1123
+ end
1124
+ end
1125
+
1126
+ # Move the cursor to an absolute codepoint index, clamped.
1127
+ def move_to(index)
1128
+ @render.synchronize do
1129
+ @cursor = index.clamp(0, @buffer.length)
1130
+ auto_update_menu # moving off the token closes the menu
1131
+ redraw
1132
+ end
1133
+ end
1134
+
1135
+ # Word-jump LEFT (Alt/Ctrl + ←): skip any whitespace immediately left, then
1136
+ # the word characters, landing at the start of the previous word.
1137
+ def word_left
1138
+ @render.synchronize do
1139
+ chars = @buffer.chars
1140
+ i = @cursor
1141
+ i -= 1 while i.positive? && chars[i - 1] =~ /\s/
1142
+ i -= 1 while i.positive? && chars[i - 1] !~ /\s/
1143
+ @cursor = i
1144
+ redraw
1145
+ end
1146
+ end
1147
+
1148
+ # Word-jump RIGHT (Alt/Ctrl + →): skip the current word then trailing
1149
+ # whitespace, landing at the start of the next word.
1150
+ def word_right
1151
+ @render.synchronize do
1152
+ chars = @buffer.chars
1153
+ i = @cursor
1154
+ i += 1 while i < chars.length && chars[i] !~ /\s/
1155
+ i += 1 while i < chars.length && chars[i] =~ /\s/
1156
+ @cursor = i
1157
+ redraw
1158
+ end
1159
+ end
1160
+
1161
+ # ↑: navigate the completion menu when open; inside a MULTI-ROW buffer
1162
+ # move the caret up one visual row (column preserved) — only from the
1163
+ # FIRST row does ↑ fall back to walking history to an older entry, the
1164
+ # readline/Claude Code convention. No-op when there's nothing older.
1165
+ def history_up
1166
+ return menu_up if menu_open?
1167
+ return if move_caret_row(-1)
1168
+
1169
+ @render.synchronize do
1170
+ entry = @history.up(@buffer)
1171
+ next if entry.nil?
1172
+
1173
+ @buffer.replace(entry)
1174
+ @cursor = @buffer.length
1175
+ redraw
1176
+ end
1177
+ end
1178
+
1179
+ # ↓: navigate the menu when open; inside a multi-row buffer move the
1180
+ # caret down one visual row — only from the LAST row does ↓ fall back to
1181
+ # walking history forward (newer entry, or back to the stashed draft).
1182
+ # No-op when not navigating history.
1183
+ def history_down
1184
+ return menu_down if menu_open?
1185
+ return if move_caret_row(1)
1186
+
1187
+ @render.synchronize do
1188
+ entry = @history.down(@buffer)
1189
+ next if entry.nil?
1190
+
1191
+ @buffer.replace(entry)
1192
+ @cursor = @buffer.length
1193
+ redraw
1194
+ end
1195
+ end
1196
+
1197
+ # Move the caret one VISUAL row up/down within a wrapped multi-row
1198
+ # buffer, keeping the screen column (clamped to the target row's
1199
+ # content). Returns true when it moved — ↑/↓ then stay inside the block;
1200
+ # false (single-row buffer, or already on the first/last row) lets the
1201
+ # caller fall back to history navigation.
1202
+ def move_caret_row(delta)
1203
+ moved = false
1204
+ @render.synchronize do
1205
+ rows, caret_row, caret_col = layout_input
1206
+ target = caret_row + delta
1207
+ next unless rows.length > 1 && target.between?(0, rows.length - 1)
1208
+
1209
+ @cursor = char_index_at(rows[target], caret_col)
1210
+ auto_update_menu # moving off the token closes the menu
1211
+ redraw
1212
+ moved = true
1213
+ end
1214
+ moved
1215
+ end
1216
+
1217
+ # The buffer index of the char at (or before) screen column +col+ on a
1218
+ # layout row — where the caret lands when ↑/↓ carries the column across
1219
+ # rows. Walks the row's chars by display width (a wide glyph is never
1220
+ # split: a column inside it resolves to its start). Clamps to the row's
1221
+ # end, and to its start when the column falls inside the prompt prefix.
1222
+ def char_index_at(row, col)
1223
+ # Continuation rows hang at the prefix width too (P12).
1224
+ width = @prefix_width
1225
+ index = row[:start]
1226
+ row[:chars].each do |ch|
1227
+ w = display_width(ch)
1228
+ break if width + w > col
1229
+
1230
+ width += w
1231
+ index += 1
1232
+ end
1233
+ index
1234
+ end
1235
+
1236
+ # Cyan the leading /command / @mention token (shared with the old prompt).
1237
+ # Plain when no completion source is wired.
1238
+ def highlight_line(line)
1239
+ return line.to_s unless @completion
1240
+
1241
+ @completion.highlight_line(line.to_s)
1242
+ end
1243
+
1244
+ # --- /command + @file completion menu ------------------------------------
1245
+ # The dropdown itself — open/refine/accept/dismiss state, candidate
1246
+ # resolution and row rendering — lives in the {CompletionMenu}; here is
1247
+ # only the keystroke plumbing and the buffer splice (the menu never
1248
+ # touches @buffer or the render mutex).
1249
+
1250
+ # Tab: with the menu open, accept the highlighted candidate; otherwise try
1251
+ # to open the menu for the token under the cursor (an explicit Tab always
1252
+ # reopens an ESC-dismissed menu). A plain Tab on non-completable text is a
1253
+ # no-op (we never insert a literal tab).
1254
+ def handle_tab
1255
+ if menu_open?
1256
+ accept_completion
1257
+ elsif @menu.open(@buffer, @cursor)
1258
+ @render.synchronize { redraw }
1259
+ end
1260
+ end
1261
+
1262
+ # Track the menu to the token under the cursor after any buffer edit or
1263
+ # cursor move (Reline parity — see CompletionMenu#auto_update).
1264
+ def auto_update_menu
1265
+ @menu.auto_update(@buffer, @cursor)
1266
+ end
1267
+
1268
+ # ↑/↓ within the menu (routed from history_up/down when the menu is open).
1269
+ # Arrowing marks the menu as NAVIGATED — an explicit accept intent, so
1270
+ # Enter on an empty argument token accepts the highlight instead of
1271
+ # submitting the buffer (see CompletionMenu#exact_command?).
1272
+ def menu_up
1273
+ @render.synchronize do
1274
+ @menu.up
1275
+ redraw
1276
+ end
1277
+ end
1278
+
1279
+ def menu_down
1280
+ @render.synchronize do
1281
+ @menu.down
1282
+ redraw
1283
+ end
1284
+ end
1285
+
1286
+ # Accept the highlighted candidate: splice it in for the token span (the
1287
+ # replacement carries a trailing space, so the next token starts clean,
1288
+ # like Reline's append char), park the cursor after it, and close the menu.
1289
+ def accept_completion
1290
+ return unless menu_open?
1291
+
1292
+ @render.synchronize do
1293
+ start, len, replacement = @menu.accept_splice
1294
+ chars = @buffer.chars
1295
+ chars[start, len] = replacement.chars
1296
+ @buffer.replace(chars.join)
1297
+ @cursor = start + replacement.chars.length
1298
+ # Re-run the menu refresh for the spliced buffer (#63): accepting a
1299
+ # command name lands the cursor in its ARGUMENT position (`/skills `),
1300
+ # so the next-context dropdown (skill names, /agents ids…) opens
1301
+ # immediately instead of one keystroke late. With nothing to complete
1302
+ # there it stays closed — the redraw then just clears the old rows.
1303
+ auto_update_menu
1304
+ redraw
1305
+ end
1306
+ end
1307
+
1308
+ # Handle a bracketed-paste body. The paste is inserted into the editable
1309
+ # buffer at the cursor like fast typing — still editable before submit.
1310
+ # A MULTI-LINE paste keeps its REAL newlines in the buffer (and so in the
1311
+ # submitted message payload — pasted code arrives at the model with its
1312
+ # line structure intact, #57); each newline renders as a real row break
1313
+ # in the multi-row input block (which supersedes the old single-row
1314
+ # ⏎-mark view), so pasted code reads back as the rows it is.
1315
+ #
1316
+ # A LARGE paste (more lines than paste.collapse_lines, default 5) does
1317
+ # not flood the buffer: it is registered in the per-session PasteStore
1318
+ # and a single "[Pasted text #N +M lines]" placeholder is inserted
1319
+ # instead — one editable token, deleted whole by backspace (see
1320
+ # #delete_back) and expanded to the full body at the chat loop's
1321
+ # message-build seam, so the model sees everything while the input and
1322
+ # the transcript echo stay one line. With no store wired (standalone /
1323
+ # tests) every paste inlines exactly as before.
1324
+ def submit_paste(text)
1325
+ return if text.nil? || text.empty?
1326
+
1327
+ body = normalize_paste_newlines(text)
1328
+ return if body.empty?
1329
+
1330
+ if @paste_store&.collapse?(body)
1331
+ insert(@paste_store.register(body))
1332
+ else
1333
+ insert(body) # at the cursor, like fast typing
1334
+ end
1335
+ end
1336
+
1337
+ # Normalize a pasted body's line endings to "\n" (terminals deliver CR
1338
+ # for Enter in raw mode) and trim TRAILING newlines so a paste that ends
1339
+ # with one never reads as a blank extra line. Interior newlines — and
1340
+ # the indentation after them — are PRESERVED end-to-end (#57).
1341
+ def normalize_paste_newlines(text)
1342
+ text.to_s.gsub(/\r\n|\r/, "\n").sub(/\n+\z/, "")
1343
+ end
1344
+
1345
+ # After ESC, parse and ACT on the escape sequence so arrows / Home / End /
1346
+ # word-jump / Delete drive the cursor instead of leaking into the buffer.
1347
+ # The {EscapeReader} consumes the byte tail (non-blocking, so a lone ESC
1348
+ # doesn't hang) and returns WHAT it means; this table maps the action to
1349
+ # the composer behavior. A lone ESC dismisses an open completion menu
1350
+ # immediately — the composer owns its reader, so there is no
1351
+ # keyseq_timeout race (D6) — and an unrecognized sequence is a quiet no-op.
1352
+ def consume_escape_sequence
1353
+ action, arg = @escapes.read_action
1354
+ case action
1355
+ when :esc then handle_lone_esc
1356
+ # A fast double-tap whose two ESC bytes landed in one read burst:
1357
+ # exactly two lone Escs back-to-back (dismiss/arm then fire — same
1358
+ # path, so menu and idle gating behave identically).
1359
+ when :esc_esc then 2.times { handle_lone_esc }
1360
+ when :alt_enter then queue_alt_enter
1361
+ when :paste then submit_paste(arg)
1362
+ when :mode_cycle then cycle_mode # Shift+Tab
1363
+ when :history_up then history_up
1364
+ when :history_down then history_down
1365
+ when :move_by then move_by(arg)
1366
+ when :word_left then word_left
1367
+ when :word_right then word_right
1368
+ when :move_home then move_to(0)
1369
+ when :move_end then move_to(@buffer.length)
1370
+ when :delete_forward then delete_forward
1371
+ end
1372
+ end
1373
+
1374
+ # Lone ESC: dismiss an open completion menu (immediate — no keyseq_timeout),
1375
+ # leaving the buffer exactly as the user typed it (no fused candidate). The
1376
+ # dismiss STICKS for the current token (see CompletionMenu#dismiss!).
1377
+ #
1378
+ # Every lone Esc also ARMS the Esc-Esc double-tap: a second lone Esc
1379
+ # within {DOUBLE_ESC_SECONDS} fires +on_double_esc+ (the idle rewind
1380
+ # picker). The menu dismiss keeps its meaning — Esc-Esc over an open
1381
+ # menu reads dismiss-then-arm, with the SECOND Esc (menu now closed)
1382
+ # triggering the chord. Idle-only: with no hook wired (the in-turn
1383
+ # composer) or while a turn is active, the chord never fires, so Esc
1384
+ # mashing mid-turn stays a quiet no-op.
1385
+ def handle_lone_esc
1386
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1387
+
1388
+ if menu_open?
1389
+ @render.synchronize do
1390
+ @menu.dismiss!
1391
+ redraw # repaint to CLEAR the now-closed menu rows above the prompt
1392
+ end
1393
+ elsif double_esc_armed?(now)
1394
+ @last_esc_at = nil
1395
+ @on_double_esc.call
1396
+ return
1397
+ end
1398
+
1399
+ @last_esc_at = now
1400
+ end
1401
+
1402
+ # True when a prior lone Esc armed the chord within the window and the
1403
+ # composer may fire it: a hook is wired AND the prompt is idle (no turn
1404
+ # running, no content streaming) — rewind is an idle-only gesture.
1405
+ def double_esc_armed?(now)
1406
+ @on_double_esc && !@turn_active && !@content_streaming &&
1407
+ @last_esc_at && (now - @last_esc_at) <= DOUBLE_ESC_SECONDS
1408
+ end
1409
+
1410
+ # Shift+Tab: ask the callback to cycle + persist the mode, then adopt the
1411
+ # new STATUS-BAR line it returns (the mode token leads the bar now — the
1412
+ # prompt is a constant "▍❯ ") and redraw under the render mutex. A nil
1413
+ # return means the mode did not change (e.g. the yolo arm toast) — no
1414
+ # repaint. The confirmation banner goes through the composer's #announce
1415
+ # (a transient row, not committed scrollback, D2/D3). The composer owns
1416
+ # NO mode logic.
1417
+ def cycle_mode
1418
+ return unless @on_mode_cycle
1419
+
1420
+ new_status = @on_mode_cycle.call
1421
+ return if new_status.nil?
1422
+
1423
+ @render.synchronize do
1424
+ @status = new_status.to_s
1425
+ redraw
1426
+ end
1427
+ end
1428
+
1429
+ # Ctrl+O: reveal the last retained reasoning aside. When the answer is
1430
+ # actively streaming, DEFER it — committing the `┊` aside now would land it
1431
+ # between answer chunks and bisect the answer (D1). The deferred reveal is
1432
+ # flushed by #end_content_stream once the answer block finishes, so it
1433
+ # renders cleanly AFTER the answer. When idle (not streaming) it reveals
1434
+ # immediately, exactly as before.
1435
+ def request_reveal
1436
+ if @content_streaming
1437
+ @deferred_reveal = true
1438
+ else
1439
+ @on_ctrl_o&.call
1440
+ end
1441
+ end
1442
+
1443
+ # Clears the transient mode-announcement row if one is showing (any
1444
+ # keystroke dismisses the toast). Redraws so the row disappears in place.
1445
+ # No-op (and no redraw) when there's nothing to clear.
1446
+ def clear_announce
1447
+ return if @announce.empty?
1448
+
1449
+ @render.synchronize do
1450
+ @announce = +""
1451
+ redraw
1452
+ end
1453
+ end
1454
+
1455
+ # Spawns the raw keystroke loop. raw(intr: true) keeps ISIG on so Ctrl+C
1456
+ # still generates SIGINT and reaches the double-tap trap installed by the
1457
+ # chat command — we never read or swallow \x03. The block form restores
1458
+ # the prior termios on exit; #stop additionally forces cooked mode.
1459
+ #
1460
+ # The loop blocks in IO.select on BOTH $stdin AND a self-pipe "stop"
1461
+ # channel, never in a bare blocking +getc+. {#stop_reader} signals the
1462
+ # stop pipe to wake the select and the loop exits WITHOUT reading $stdin —
1463
+ # so a keystroke that arrives during teardown is left in the terminal for
1464
+ # TTY::Prompt instead of being swallowed by the dying reader (#80). We only
1465
+ # +getc+ once select reports $stdin readable, and only when the stop pipe
1466
+ # is NOT also ready, so the handoff to an approval menu never races a
1467
+ # buffered byte.
1468
+ def start_reader
1469
+ stop_r, stop_w = IO.pipe
1470
+ @stop_pipe = stop_w
1471
+ Thread.new do
1472
+ @input.raw(intr: true) do
1473
+ loop do
1474
+ ready, = IO.select([@input, stop_r])
1475
+ break if ready.include?(stop_r) # stop signalled — don't read stdin
1476
+ next unless ready.include?(@input)
1477
+
1478
+ ch = @input.getc
1479
+ break if ch.nil? # EOF / stdin closed
1480
+
1481
+ result = handle_key(ch)
1482
+ break if result == :quit
1483
+ end
1484
+ end
1485
+ rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
1486
+ # stdin went away (closed/redirected mid-turn) or isn't a raw-capable
1487
+ # device — stop reading; the turn keeps running. Nothing to surface.
1488
+ ensure
1489
+ stop_r.close unless stop_r.closed?
1490
+ @input.cooked! if tty?
1491
+ end
1492
+ end
1493
+
1494
+ # Stop the raw reader thread deterministically (no kill race). Shared by
1495
+ # #stop and #suspend so the thread lifecycle stays in one place. We signal
1496
+ # the self-pipe to wake the reader's IO.select so the loop exits on its own
1497
+ # WITHOUT a +getc+, then +join+ so the thread is fully gone (and out of raw
1498
+ # mode) before control returns. This guarantees the reader is not mid-+getc+
1499
+ # when the caller hands $stdin to TTY::Prompt, so the approval menu receives
1500
+ # the very first keystroke (#80).
1501
+ #
1502
+ # +kill+ remains as a fallback ONLY for a reader with no stop pipe (e.g. a
1503
+ # stubbed reader in unit tests) — there it is the sole exit. For the real
1504
+ # reader the join below always returns via the pipe signal, so the kill is a
1505
+ # no-op on an already-finished thread and never races a buffered byte.
1506
+ # Safe-on-nil and idempotent.
1507
+ def stop_reader
1508
+ if @stop_pipe && !@stop_pipe.closed?
1509
+ # The reader may have ALREADY exited (e.g. EOF) and closed its read end
1510
+ # of the self-pipe before we signal — writing then raises EPIPE. The
1511
+ # signal is moot there (the reader is gone), so swallow it; the join
1512
+ # below still returns. (Errno::EPIPE / IOError on a half-closed pipe.)
1513
+ begin
1514
+ @stop_pipe.write("x")
1515
+ rescue Errno::EPIPE, IOError
1516
+ nil
1517
+ end
1518
+ @stop_pipe.close
1519
+ elsif @reader
1520
+ @reader.kill # no stop pipe (stubbed/edge): kill is the only way out
1521
+ end
1522
+ @reader&.join
1523
+ @reader = nil
1524
+ @stop_pipe = nil
1525
+ end
1526
+
1527
+ # Clear the prompt row (and a live partial row above it, if any) and leave
1528
+ # the cursor on a clean line. Shared teardown for #stop and #suspend. Must
1529
+ # be called while holding @render.
1530
+ def clear_live_region_to_clean_line
1531
+ @output.print(PASTE_OFF)
1532
+ @region.clear
1533
+ @partial = +""
1534
+ @cards = []
1535
+ @menu.hide!
1536
+ @announce = +""
1537
+ @output.flush
1538
+ end
1539
+
1540
+ def printable?(ch)
1541
+ return false unless ch.respond_to?(:valid_encoding?) && ch.valid_encoding?
1542
+
1543
+ ch.bytesize > 1 || ch.ord >= 0x20
1544
+ end
1545
+
1546
+ # Terminal width in columns. winsize can report 0 (or a non-positive
1547
+ # value) in some terminals/multiplexers, at startup, or a zero-height
1548
+ # window — treat anything non-positive as "unknown" and fall back, never
1549
+ # return <= 0 (the clamp/slice math would otherwise crash the turn).
1550
+ def compute_cols
1551
+ cols = begin
1552
+ positive_int(@output.winsize.last)
1553
+ rescue StandardError
1554
+ nil
1555
+ end
1556
+ cols ||= begin
1557
+ positive_int(IO.console&.winsize&.last)
1558
+ rescue StandardError
1559
+ nil
1560
+ end
1561
+ cols || 80
1562
+ end
1563
+
1564
+ def positive_int(value)
1565
+ value.is_a?(Integer) && value.positive? ? value : nil
1566
+ end
1567
+
1568
+ def tty?
1569
+ @input.tty?
1570
+ rescue StandardError
1571
+ false
1572
+ end
1573
+
1574
+ def install_winch_trap
1575
+ return unless Signal.list.key?("WINCH")
1576
+
1577
+ @prev_winch = Signal.trap("WINCH") do
1578
+ # Trap-context: resize takes the mutex, which is allowed here because
1579
+ # the handler runs on its own and never re-enters under the same lock.
1580
+ # Wrapped in rescue so a redraw failure never crashes the process.
1581
+
1582
+ resize
1583
+ rescue StandardError
1584
+ nil
1585
+ end
1586
+ rescue ArgumentError
1587
+ @prev_winch = nil
1588
+ end
1589
+
1590
+ def restore_winch_trap
1591
+ return unless Signal.list.key?("WINCH")
1592
+
1593
+ Signal.trap("WINCH", @prev_winch || "DEFAULT")
1594
+ rescue ArgumentError
1595
+ nil
1596
+ end
1597
+ end
1598
+ end
1599
+ end