octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Octo
6
+ module UI2
7
+ # An *owned* progress indicator.
8
+ #
9
+ # Why this exists
10
+ # ---------------
11
+ # The previous design had a single, globally-shared spinner slot on
12
+ # UiController (`@progress_id` / `@progress_thread` / `@progress_message`
13
+ # / `@progress_start_time`). Every caller — Agent#run, Agent#think,
14
+ # LlmCaller retry, idle compression, MemoryUpdater — wrote into the
15
+ # same slot and hoped to remember to close it. When control flow was
16
+ # interrupted (user types a new message during idle compression,
17
+ # AgentInterrupted is raised) a ticker thread would be left running
18
+ # and a new spinner would reuse the same entry, producing two
19
+ # concurrent tickers repainting the same line in different colors.
20
+ #
21
+ # In the new design each caller owns a ProgressHandle. The handle
22
+ # encapsulates:
23
+ #
24
+ # - its own OutputBuffer entry id (may become nil while another
25
+ # handle is on top — see "Stack semantics" below);
26
+ # - its own ticker thread (exactly one per handle, stopped and
27
+ # joined on +finish+);
28
+ # - its own message, style, start time;
29
+ #
30
+ # Owners (UiController) keep a stack of live handles and follow the
31
+ # protocol below.
32
+ #
33
+ # Owner protocol
34
+ # --------------
35
+ # An "owner" must respond to three methods:
36
+ #
37
+ # register_progress(handle) -> Integer (entry_id) | nil
38
+ # Called exactly once when the handle starts. The owner pushes
39
+ # the handle onto its stack, creates an OutputBuffer entry, and
40
+ # returns that entry id. Before pushing, the owner may detach
41
+ # the previous top-of-stack (Plan B: its entry is removed from
42
+ # the buffer until the new top finishes).
43
+ #
44
+ # unregister_progress(handle, final_frame:) -> void
45
+ # Called exactly once when the handle finishes. The owner pops
46
+ # the handle from its stack, renders +final_frame+ into the
47
+ # entry (or removes the entry if +final_frame+ is nil), and may
48
+ # reattach the new top-of-stack if one exists.
49
+ #
50
+ # render_frame(handle, frame) -> void
51
+ # Called by the ticker (and by +update+) on every paint. The
52
+ # owner is responsible for ignoring the call if +handle+ is not
53
+ # currently top-of-stack — the handle itself does NOT know about
54
+ # the stack.
55
+ #
56
+ # Stack semantics (Plan B)
57
+ # ------------------------
58
+ # When a new handle is pushed on top of an existing one, the lower
59
+ # handle's OutputBuffer entry is removed (owner calls
60
+ # +__detach_entry!+ on it). When the new top finishes, the owner
61
+ # re-creates an entry for the lower handle and calls
62
+ # +__reattach_entry!+ with the new id. This keeps the visible output
63
+ # clean: exactly one progress line on screen at a time, and no
64
+ # visual "stacking" of frozen progress lines.
65
+ #
66
+ # Thread safety
67
+ # -------------
68
+ # The handle uses a Monitor (reentrant) to serialize state changes
69
+ # between the caller thread and the ticker thread. Public methods
70
+ # (+start+, +update+, +finish+) are safe to call from any thread.
71
+ class ProgressHandle
72
+ # Default tick interval (seconds). Matches the old global spinner
73
+ # cadence. Tests may pass a smaller interval for speed.
74
+ DEFAULT_TICK_INTERVAL = 0.25
75
+
76
+ # Style hint for the renderer. The owner decides what colors to use;
77
+ # the handle only forwards the hint as part of the frame metadata
78
+ # so the renderer can pick between e.g. yellow "working" and gray
79
+ # "quiet" palettes.
80
+ #
81
+ # :primary — foreground task, should also update sessionbar
82
+ # :quiet — background task (idle compression, retries); does
83
+ # NOT bump sessionbar to 'working'
84
+ VALID_STYLES = %i[primary quiet].freeze
85
+
86
+ attr_reader :entry_id, :message, :style, :start_time
87
+
88
+ # Threshold (seconds) below which a +quiet_on_fast_finish+ handle
89
+ # collapses its final frame — i.e. the progress line is REMOVED
90
+ # from the output buffer instead of being kept as a permanent
91
+ # "Executing foo… (0s)" log line. Operations that finish this fast
92
+ # didn't need a spinner in the first place; keeping the final
93
+ # frame would be visual noise.
94
+ FAST_FINISH_THRESHOLD_SECONDS = 2
95
+
96
+ # Show "Thinking for Ns" once the gap since the last LLM stream
97
+ # chunk reaches this many seconds. Bedrock often pauses 5–18s
98
+ # while generating large content blocks (long tool_use JSON in
99
+ # particular); without this hint users assume the agent is stuck.
100
+ IDLE_HINT_THRESHOLD_SECONDS = 2
101
+
102
+ # @param owner [#register_progress, #unregister_progress, #render_frame]
103
+ # @param message [String] Initial progress message.
104
+ # @param style [Symbol] :primary or :quiet (see VALID_STYLES).
105
+ # @param tick_interval [Float] Seconds between auto-renders.
106
+ # @param quiet_on_fast_finish [Boolean] When true and the elapsed
107
+ # time on +finish+ is under FAST_FINISH_THRESHOLD_SECONDS, the
108
+ # owner is told to remove the progress entry (+final_frame: nil+)
109
+ # instead of committing a permanent final frame. This is the
110
+ # preferred mode for tool execution wrappers, where fast tools
111
+ # (edit, write, read) don't need a lingering "Executing edit…
112
+ # (0s)" line after completion.
113
+ # @param clock [#call] Test hook: returns current Time (default Time.now).
114
+ def initialize(owner:, message:, style: :primary, tick_interval: DEFAULT_TICK_INTERVAL, quiet_on_fast_finish: false, clock: -> { Time.now })
115
+ unless VALID_STYLES.include?(style)
116
+ raise ArgumentError, "unknown progress style: #{style.inspect} (valid: #{VALID_STYLES.inspect})"
117
+ end
118
+
119
+ @owner = owner
120
+ @message = message.to_s
121
+ @style = style
122
+ @tick_interval = tick_interval
123
+ @quiet_on_fast_finish = quiet_on_fast_finish
124
+ @clock = clock
125
+
126
+ @entry_id = nil
127
+ @start_time = nil
128
+ @ticker = nil
129
+ @state = :fresh # :fresh → :running → :closed
130
+ @metadata = {}
131
+ @last_chunk_at = nil
132
+ @monitor = Monitor.new
133
+ end
134
+
135
+ # Start rendering. Registers with the owner (allocating an entry id
136
+ # and pushing onto its stack) and launches the ticker thread.
137
+ #
138
+ # @return [self]
139
+ def start
140
+ @monitor.synchronize do
141
+ return self unless @state == :fresh
142
+
143
+ @state = :running
144
+ @start_time = @clock.call
145
+ @last_chunk_at = @start_time
146
+ @entry_id = @owner.register_progress(self)
147
+ end
148
+
149
+ # Fire one initial frame synchronously so the user sees the
150
+ # spinner immediately — no "blank line for half a second" bug.
151
+ render_now
152
+
153
+ start_ticker
154
+ self
155
+ end
156
+
157
+ # Change the message or metadata mid-flight. Safe to call from any
158
+ # thread. Triggers an immediate re-render (if top-of-stack; the
159
+ # owner will ignore the call otherwise).
160
+ #
161
+ # @param message [String, nil]
162
+ # @param metadata [Hash] Renderer-specific extras (e.g. retry counts).
163
+ def update(message: nil, metadata: nil)
164
+ @monitor.synchronize do
165
+ return if @state != :running
166
+ @message = message.to_s if message
167
+ if metadata
168
+ @metadata = metadata
169
+ @last_chunk_at = @clock.call
170
+ end
171
+ end
172
+ end
173
+
174
+ # Stop the ticker, render one final frame, and unregister from the
175
+ # owner. Idempotent — calling twice is a no-op.
176
+ #
177
+ # @param final_message [String, nil] Optional override for the last
178
+ # frame. If nil, the handle composes "<message>… (<elapsed>s)".
179
+ def finish(final_message: nil)
180
+ snapshot = @monitor.synchronize do
181
+ return if @state != :running
182
+ @state = :closed
183
+ { message: final_message || @message, elapsed: elapsed_seconds }
184
+ end
185
+
186
+ stop_ticker
187
+ # Collapse fast-finishers to a removed entry so tools that complete
188
+ # in under FAST_FINISH_THRESHOLD_SECONDS don't leave a permanent
189
+ # "Executing foo… (0s)" line. The owner interprets final_frame: nil
190
+ # as "remove the entry entirely".
191
+ final_frame =
192
+ if @quiet_on_fast_finish && snapshot[:elapsed] < FAST_FINISH_THRESHOLD_SECONDS
193
+ nil
194
+ else
195
+ compose_final_frame(snapshot[:message], snapshot[:elapsed])
196
+ end
197
+ @owner.unregister_progress(self, final_frame: final_frame)
198
+ end
199
+ alias_method :cancel, :finish
200
+
201
+ # True while the ticker thread is alive.
202
+ def ticker_alive?
203
+ t = @ticker
204
+ !!(t && t.alive?)
205
+ end
206
+
207
+ # True between +start+ and +finish+.
208
+ def running?
209
+ @monitor.synchronize { @state == :running }
210
+ end
211
+
212
+ # Compose the current visual frame. The owner gets this string via
213
+ # +render_frame+ and is responsible for writing it into the entry.
214
+ def current_frame
215
+ @monitor.synchronize do
216
+ compose_frame(@message, elapsed_seconds, @metadata, idle_seconds)
217
+ end
218
+ end
219
+
220
+ # ---- owner-facing hooks (Plan B stack machinery) ----------------
221
+ #
222
+ # These double-underscore methods are part of the owner protocol.
223
+ # They are NOT meant for general callers.
224
+
225
+ # Owner calls this when this handle is being pushed below a new
226
+ # top. The handle loses its OutputBuffer entry until restored.
227
+ def __detach_entry!
228
+ @monitor.synchronize { @entry_id = nil }
229
+ end
230
+
231
+ # Owner calls this when this handle becomes top-of-stack again
232
+ # (the handle above finished). A fresh entry id is supplied.
233
+ def __reattach_entry!(new_entry_id)
234
+ @monitor.synchronize { @entry_id = new_entry_id }
235
+ render_now
236
+ end
237
+
238
+ # Like __reattach_entry! but skips the render_now hop. Used by the
239
+ # owner when it has just painted a frame into the new entry itself
240
+ # (e.g. while rotating the handle to remain at the buffer tail) and
241
+ # is still inside its own synchronization — calling render_now there
242
+ # would re-enter the owner's mutex.
243
+ def __rebind_entry!(new_entry_id)
244
+ @monitor.synchronize { @entry_id = new_entry_id }
245
+ end
246
+
247
+ # Test hook: force a synchronous render regardless of tick cadence.
248
+ def __force_render!
249
+ render_now
250
+ end
251
+
252
+ private def start_ticker
253
+ @ticker = Thread.new do
254
+ Thread.current.name = "progress-ticker-#{object_id}"
255
+ begin
256
+ loop do
257
+ sleep @tick_interval
258
+ break if @monitor.synchronize { @state != :running }
259
+ render_now
260
+ end
261
+ rescue StandardError
262
+ # Ticker must never crash the process — the caller's main
263
+ # thread still owns the real control flow.
264
+ end
265
+ end
266
+ end
267
+
268
+ private def stop_ticker
269
+ t = @ticker
270
+ return unless t
271
+ # The loop checks @state on each iteration, so once we're
272
+ # :closed the next wake-up exits cleanly. Give it 1s; if
273
+ # something is stuck, kill as a last resort.
274
+ joined = t.join(1.0)
275
+ t.kill unless joined
276
+ @ticker = nil
277
+ end
278
+
279
+ private def render_now
280
+ frame = current_frame
281
+ @owner.render_frame(self, frame)
282
+ rescue StandardError
283
+ # Rendering must never propagate.
284
+ end
285
+
286
+ private def elapsed_seconds
287
+ return 0 unless @start_time
288
+ (@clock.call - @start_time).to_i
289
+ end
290
+
291
+ # Seconds since the last metadata update (i.e. the last LLM stream
292
+ # chunk that carried token info). Used to surface "Thinking for Ns"
293
+ # in the live frame so users can see the agent isn't stuck even
294
+ # when token counts plateau during long Bedrock content blocks.
295
+ private def idle_seconds
296
+ return 0 unless @last_chunk_at
297
+ (@clock.call - @last_chunk_at).to_i
298
+ end
299
+
300
+ # Live-frame format:
301
+ # "<message>… (<elapsed>s · ↓N tokens · reasoning…)"
302
+ # The "reasoning" tail only appears once tokens have started
303
+ # streaming AND the gap since the last chunk reaches the threshold
304
+ # — signalling the model is between tool_use blocks doing extended
305
+ # thinking. No seconds shown there to avoid duplicating elapsed;
306
+ # animated dots (1→2→3) provide the "still alive" cue.
307
+ private def compose_frame(message, elapsed, metadata, idle = 0)
308
+ head = message.to_s
309
+ if metadata && (attempt = metadata[:attempt]) && (total = metadata[:total])
310
+ head = "#{head} [#{attempt}/#{total}]"
311
+ end
312
+
313
+ token_part = metadata && format_token_progress(metadata)
314
+
315
+ suffix_parts = []
316
+ suffix_parts << "#{elapsed}s" if elapsed > 0
317
+ suffix_parts << token_part if token_part
318
+ if token_part && idle >= IDLE_HINT_THRESHOLD_SECONDS
319
+ suffix_parts << "reasoning #{spinner_frame} "
320
+ end
321
+
322
+ return "#{head}…" if suffix_parts.empty?
323
+ "#{head}… (#{suffix_parts.join(" · ")})"
324
+ end
325
+
326
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
327
+ SPINNER_INTERVAL_MS = 250
328
+
329
+ private def spinner_frame
330
+ ms = (@clock.call.to_f * 1000).to_i
331
+ SPINNER_FRAMES[(ms / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length]
332
+ end
333
+
334
+ # Render LLM streaming token counts as "↑1.2k ↓234 tokens".
335
+ # When input_tokens is unknown (e.g. OpenAI-compat streaming where
336
+ # prompt_tokens only arrives in the final frame), shows "↑—" so the
337
+ # column doesn't flicker between absent / present.
338
+ private def format_token_progress(metadata)
339
+ output = metadata[:output_tokens]
340
+ return nil if output.nil? || output.to_i <= 0
341
+ "↓ #{compact_count(output.to_i)} tokens"
342
+ end
343
+
344
+ private def compact_count(n)
345
+ return n.to_s if n < 1000
346
+ if n < 1_000_000
347
+ k = n / 1000.0
348
+ k >= 10 ? "#{k.to_i}k" : "%.1fk" % k
349
+ else
350
+ m = n / 1_000_000.0
351
+ m >= 10 ? "#{m.to_i}M" : "%.1fM" % m
352
+ end
353
+ end
354
+
355
+ # Final frame (used by +finish+). Same as +compose_frame+ but we
356
+ # always include elapsed time so the last line carries a duration.
357
+ private def compose_final_frame(message, elapsed)
358
+ "#{message}… (#{elapsed}s)"
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ class ProgressIndicator
5
+ def initialize(verbose: false, message: nil)
6
+ @verbose = verbose
7
+ @start_time = nil
8
+ @custom_message = message
9
+ @thinking_verb = message || THINKING_VERBS.sample
10
+ @running = false
11
+ @update_thread = nil
12
+ end
13
+
14
+ def start
15
+ @start_time = Time.now
16
+ @running = true
17
+ # Save cursor position after the [..] symbol
18
+ print "\e[s" # Save cursor position
19
+ print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt)")
20
+
21
+ # Start background thread to update elapsed time
22
+ @update_thread = Thread.new do
23
+ while @running
24
+ sleep 0.1
25
+ update if @running
26
+ end
27
+ end
28
+ end
29
+
30
+ def update
31
+ return unless @start_time
32
+
33
+ elapsed = (Time.now - @start_time).to_i
34
+ print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt · #{elapsed}s)")
35
+ end
36
+
37
+ def finish
38
+ @running = false
39
+ @update_thread&.join
40
+ # Restore cursor and clear to end of line
41
+ print "\e[u" # Restore cursor position
42
+ print "\e[K" # Clear to end of line
43
+ puts "" # Add newline after finishing
44
+ end
45
+
46
+
47
+ def print_thinking_status(text)
48
+ print "\e[u" # Restore cursor position (to after [..] symbol)
49
+ print "\e[K" # Clear to end of line from cursor
50
+ print text
51
+ print " "
52
+ $stdout.flush
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-screen"
4
+ require "io/console"
5
+ require_relative "../utils/encoding"
6
+
7
+ module Octo
8
+ module UI2
9
+ # ScreenBuffer manages terminal screen state and provides low-level rendering primitives
10
+ class ScreenBuffer
11
+ attr_reader :width, :height
12
+
13
+ def initialize
14
+ @width = TTY::Screen.width
15
+ @height = TTY::Screen.height
16
+ @buffer = []
17
+ @last_input_time = nil
18
+ @rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
19
+
20
+ # Keep stdin in UTF-8 mode so getc returns complete multi-byte characters (e.g. CJK).
21
+ # Switching to BINARY would cause getc to return one byte at a time, breaking Chinese input.
22
+ $stdin.set_encoding('UTF-8')
23
+ end
24
+
25
+ # Move cursor to specific position (0-indexed)
26
+ # @param row [Integer] Row position
27
+ # @param col [Integer] Column position
28
+ def move_cursor(row, col)
29
+ print "\e[#{row + 1};#{col + 1}H"
30
+ end
31
+
32
+ # Clear screen with different modes:
33
+ # :preserve - clear visible screen, scrollback history preserved (default)
34
+ # :current - cursor to top-left and erase to end, no new scrollback produced
35
+ # :reset - clear visible screen AND scrollback history (full reset)
36
+ # @param mode [Symbol] Clear mode (:preserve, :current, :reset)
37
+ def clear_screen(mode: :preserve)
38
+ case mode
39
+ when :reset
40
+ print "\e[3J" # erase scrollback buffer
41
+ print "\e[H\e[J" # cursor to top-left, erase to end of screen
42
+ when :current
43
+ print "\e[H\e[J" # cursor to top-left, erase to end of screen
44
+ else # :preserve
45
+ print "\e[2J\e[H" # erase visible screen, scrollback preserved
46
+ end
47
+ move_cursor(0, 0)
48
+ end
49
+
50
+ # Clear current line
51
+ def clear_line
52
+ print "\e[2K"
53
+ end
54
+
55
+ # Clear from cursor to end of line
56
+ def clear_to_eol
57
+ print "\e[K"
58
+ end
59
+
60
+ # Hide cursor
61
+ def hide_cursor
62
+ print "\e[?25l"
63
+ end
64
+
65
+ # Show cursor
66
+ def show_cursor
67
+ print "\e[?25h"
68
+ end
69
+
70
+ # Save cursor position
71
+ def save_cursor
72
+ print "\e[s"
73
+ end
74
+
75
+ # Restore cursor position
76
+ def restore_cursor
77
+ print "\e[u"
78
+ end
79
+
80
+ # Enable alternative screen buffer (like vim/less)
81
+ def enable_alt_screen
82
+ print "\e[?1049h"
83
+ end
84
+
85
+ # Disable alternative screen buffer
86
+ def disable_alt_screen
87
+ print "\e[?1049l"
88
+ end
89
+
90
+ # Set scroll region (DECSTBM - DEC Set Top and Bottom Margins)
91
+ # Content written in this region will scroll, content outside will stay fixed
92
+ # @param top [Integer] Top row (1-indexed)
93
+ # @param bottom [Integer] Bottom row (1-indexed)
94
+ def set_scroll_region(top, bottom)
95
+ print "\e[#{top};#{bottom}r"
96
+ end
97
+
98
+ # Reset scroll region to full screen
99
+ def reset_scroll_region
100
+ print "\e[r"
101
+ end
102
+
103
+ # Scroll the scroll region up by n lines
104
+ # @param n [Integer] Number of lines to scroll
105
+ def scroll_up(n = 1)
106
+ print "\e[#{n}S"
107
+ end
108
+
109
+ # Scroll the scroll region down by n lines
110
+ # @param n [Integer] Number of lines to scroll
111
+ def scroll_down(n = 1)
112
+ print "\e[#{n}T"
113
+ end
114
+
115
+ # Get current screen dimensions
116
+ def update_dimensions
117
+ @width = TTY::Screen.width
118
+ @height = TTY::Screen.height
119
+ end
120
+
121
+ # Enable raw mode (disable line buffering)
122
+ def enable_raw_mode
123
+ $stdin.raw!
124
+ end
125
+
126
+ # Disable raw mode
127
+ def disable_raw_mode
128
+ $stdin.cooked!
129
+ end
130
+
131
+ # Read a single character without echo
132
+ # @param timeout [Float] Timeout in seconds (nil for blocking)
133
+ # @return [String, nil] Character or nil if timeout
134
+ def read_char(timeout: nil)
135
+ if timeout
136
+ return nil unless IO.select([$stdin], nil, nil, timeout)
137
+ end
138
+
139
+ $stdin.getc
140
+ end
141
+
142
+ # Read a key including special keys (arrows, etc.)
143
+ # @param timeout [Float] Timeout in seconds
144
+ # @return [Symbol, String, Hash, nil] Key symbol, character, or { type: :rapid_input, text: String }
145
+ def read_key(timeout: nil)
146
+ current_time = Time.now.to_f
147
+ is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
148
+ @last_input_time = current_time
149
+
150
+ char = read_char(timeout: timeout)
151
+ return nil unless char
152
+
153
+ # Convert raw BINARY bytes to valid UTF-8. Invalid/undefined bytes are dropped
154
+ # rather than raising ArgumentError (which would crash the input loop).
155
+ char = safe_to_utf8(char) if char.is_a?(String)
156
+
157
+ # Handle escape sequences for special keys
158
+ if char == "\e"
159
+ # Non-blocking read for escape sequence
160
+ char2 = read_char(timeout: 0.01)
161
+ return :escape unless char2
162
+
163
+ if char2 == "["
164
+ char3 = read_char(timeout: 0.01)
165
+ case char3
166
+ when "A" then return :up_arrow
167
+ when "B" then return :down_arrow
168
+ when "C" then return :right_arrow
169
+ when "D" then return :left_arrow
170
+ when "H" then return :home
171
+ when "F" then return :end
172
+ when "Z" then return :shift_tab
173
+ when "3"
174
+ char4 = read_char(timeout: 0.01)
175
+ return :delete if char4 == "~"
176
+ end
177
+ end
178
+ end
179
+
180
+ # Check if there are more characters available (for rapid input detection)
181
+ has_more_input = IO.select([$stdin], nil, nil, 0)
182
+
183
+ # If this is rapid input or there are more characters available
184
+ if is_rapid_input || has_more_input
185
+ buffer = char.to_s.dup
186
+
187
+ # Keep reading available characters
188
+ loop_count = 0
189
+ empty_checks = 0
190
+
191
+ loop do
192
+ # Check if there's data available immediately
193
+ has_data = IO.select([$stdin], nil, nil, 0)
194
+
195
+ if has_data
196
+ next_char = $stdin.getc rescue nil
197
+ break unless next_char
198
+
199
+ next_char = safe_to_utf8(next_char)
200
+ buffer << next_char
201
+ loop_count += 1
202
+ empty_checks = 0 # Reset empty check counter
203
+ else
204
+ # No immediate data, but wait a bit to see if more is coming
205
+ # This handles the case where paste data arrives in chunks
206
+ empty_checks += 1
207
+ if empty_checks == 1
208
+ # First empty check - wait 10ms for more data
209
+ sleep 0.01
210
+ else
211
+ # Second empty check - really no more data
212
+ break
213
+ end
214
+ end
215
+ end
216
+
217
+ # If we buffered multiple characters or newlines, treat as rapid input (paste)
218
+ if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
219
+ # Ensure the accumulated buffer is valid UTF-8 before regex operations
220
+ buffer = safe_to_utf8(buffer)
221
+ # Remove any trailing \r or \n from rapid input buffer
222
+ cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
223
+ return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
224
+ end
225
+
226
+ # Single character, continue to normal handling
227
+ char = buffer[0] if buffer.length == 1
228
+ end
229
+
230
+ # Handle control characters
231
+ case char
232
+ when "\r" then :enter
233
+ when "\n" then :newline # Shift+Enter sends \n
234
+ when "\u007F", "\b" then :backspace
235
+ when "\u0001" then :ctrl_a
236
+ when "\u0002" then :ctrl_b
237
+ when "\u0003" then :ctrl_c
238
+ when "\u0004" then :ctrl_d
239
+ when "\u0005" then :ctrl_e
240
+ when "\u0006" then :ctrl_f
241
+ when "\u000B" then :ctrl_k
242
+ when "\u000C" then :ctrl_l
243
+ when "\u000F" then :ctrl_o
244
+ when "\u0012" then :ctrl_r
245
+ when "\u0015" then :ctrl_u
246
+ when "\u0016" then :ctrl_v
247
+ when "\u0017" then :ctrl_w
248
+ when "\t" then :tab
249
+ else char
250
+ end
251
+ end
252
+
253
+ # Flush output
254
+ def flush
255
+ $stdout.flush
256
+ end
257
+
258
+
259
+ # Ensure a string is valid UTF-8.
260
+ # stdin stays in UTF-8 mode so getc returns complete characters (including CJK).
261
+ # This method handles the rare case where an invalid byte slips through
262
+ # (e.g. a stray terminal escape or a partial sequence) by scrubbing it out
263
+ # rather than letting ArgumentError crash the input loop.
264
+ # @param str [String] String from getc (UTF-8 encoded, but may have invalid bytes)
265
+ # @return [String] Valid UTF-8 string
266
+ private def safe_to_utf8(str)
267
+ return str if str.valid_encoding?
268
+
269
+ Octo::Utils::Encoding.sanitize_utf8(str)
270
+ end
271
+ end
272
+ end
273
+ end