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,807 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screen_buffer"
4
+ require_relative "output_buffer"
5
+ require_relative "../utils/encoding"
6
+
7
+ module Octo
8
+ module UI2
9
+ # LayoutManager coordinates the split-screen layout:
10
+ # [ scrollable output area ]
11
+ # [ gap / todo / input (fixed) ]
12
+ #
13
+ # Responsibilities:
14
+ # - Own an OutputBuffer (logical source of truth for output content).
15
+ # - Translate buffer mutations into screen paints, handling:
16
+ # * Native terminal scrolling when output overflows the output area.
17
+ # * Committing scrolled lines to the buffer (so they are never repainted
18
+ # from the buffer again — prevents the classic "double render on
19
+ # scroll up" bug).
20
+ # - Keep the fixed area (gap + todo + input) pinned at the bottom of the
21
+ # screen, repainting it only when it is dirty.
22
+ #
23
+ # Public API (id-based, preferred):
24
+ # append(content, kind: :text) -> id # add entry, returns id
25
+ # replace_entry(id, content) # edit entry's content
26
+ # remove_entry(id) # drop entry
27
+ #
28
+ # Legacy API (shims, still used by InlineInput / progress):
29
+ # append_output(content) -> id # alias for append
30
+ # update_last_line(content, old_n, id: nil) # uses id if given
31
+ # remove_last_line(n, id: nil) # uses id if given
32
+ class LayoutManager
33
+ attr_reader :screen, :input_area, :todo_area, :buffer
34
+
35
+ def initialize(input_area:, todo_area: nil)
36
+ @screen = ScreenBuffer.new
37
+ @input_area = input_area
38
+ @todo_area = todo_area
39
+ @buffer = OutputBuffer.new
40
+ @render_mutex = Mutex.new
41
+
42
+ @output_row = 0 # Next output row to paint into
43
+ @last_fixed_area_height = 0
44
+ @fullscreen_mode = false
45
+ @resize_pending = false
46
+
47
+ # Tracks the most recent append's id so the legacy
48
+ # update_last_line / remove_last_line shims still work without the
49
+ # caller threading an id through.
50
+ @last_append_id = nil
51
+
52
+ calculate_layout
53
+ setup_resize_handler
54
+ end
55
+
56
+ # -----------------------------------------------------------------------
57
+ # Layout math
58
+ # -----------------------------------------------------------------------
59
+
60
+ def calculate_layout
61
+ todo_height = @todo_area&.height || 0
62
+ input_height = @input_area.required_height
63
+ gap_height = 1
64
+
65
+ @output_height = screen.height - gap_height - todo_height - input_height
66
+ @output_height = [1, @output_height].max
67
+
68
+ @gap_row = @output_height
69
+ @todo_row = @gap_row + gap_height
70
+ @input_row = @todo_row + todo_height
71
+
72
+ @input_area.row = @input_row
73
+ end
74
+
75
+ def fixed_area_height
76
+ todo_h = @todo_area&.height || 0
77
+ input_h = @input_area.required_height
78
+ 1 + todo_h + input_h
79
+ end
80
+
81
+ def fixed_area_start_row
82
+ screen.height - fixed_area_height
83
+ end
84
+
85
+ # -----------------------------------------------------------------------
86
+ # Public output API (id-based)
87
+ # -----------------------------------------------------------------------
88
+
89
+ # Append an output entry. Returns the entry id so callers can later
90
+ # replace_entry / remove_entry. Multi-line content is wrapped and
91
+ # stored as one logical entry.
92
+ def append(content, kind: :text)
93
+ return nil if content.nil?
94
+ content = sanitize(content)
95
+
96
+ @render_mutex.synchronize do
97
+ lines = wrap_content_to_lines(content)
98
+ id = @buffer.append(lines, kind: kind)
99
+ @last_append_id = id
100
+
101
+ paint_new_lines(lines) unless @fullscreen_mode
102
+ render_fixed_areas
103
+ screen.flush
104
+ id
105
+ end
106
+ end
107
+
108
+ # Legacy: append, return id (callers that ignore it still work).
109
+ def append_output(content)
110
+ append(content)
111
+ end
112
+
113
+ # Replace an existing entry's content. The screen is updated in place
114
+ # if the entry still lives in the output area; otherwise (committed
115
+ # to scrollback, or partially scrolled off) this is a silent no-op.
116
+ def replace_entry(id, content)
117
+ return if id.nil? || content.nil?
118
+ content = sanitize(content)
119
+
120
+ @render_mutex.synchronize do
121
+ entry = @buffer.entry_by_id(id)
122
+ # Skip if gone, fully committed, or only partially visible (its
123
+ # prefix is already in terminal scrollback and cannot be edited).
124
+ return if entry.nil? || entry.committed
125
+ return if (entry.committed_line_offset || 0) > 0
126
+
127
+ old_lines = entry.lines.dup
128
+ new_lines = wrap_content_to_lines(content)
129
+ if old_lines == new_lines
130
+ screen.flush
131
+ return
132
+ end
133
+ @buffer.replace(id, new_lines)
134
+
135
+ unless @fullscreen_mode
136
+ # repaint_entry_in_place relies on the entry being the tail of
137
+ # live entries (it computes the entry's top row from @output_row
138
+ # and old height). When the entry is NOT the tail — e.g. a
139
+ # background progress ticker fires after a newer entry was
140
+ # appended — that assumption silently corrupts the screen:
141
+ # the new frame gets painted at the tail's row, clobbering the
142
+ # latest log line, and @output_row is reset to a position that
143
+ # predates appended-but-still-live entries. On next scroll,
144
+ # those stale-now-present rows end up in terminal scrollback as
145
+ # duplicated lines (the user-visible "output repeats" bug).
146
+ #
147
+ # For non-tail replaces, fall back to a full rebuild of the
148
+ # output area from the buffer. Slower, but correct regardless
149
+ # of where the entry lives.
150
+ is_tail = @buffer.live_entries.last&.id == id
151
+ if is_tail
152
+ repaint_entry_in_place(entry, old_lines, new_lines)
153
+ else
154
+ render_output_from_buffer
155
+ end
156
+ end
157
+ render_fixed_areas
158
+ screen.flush
159
+ end
160
+ end
161
+
162
+ # Is this id still a live (not yet committed to scrollback) entry?
163
+ # Cheap probe callers use before deciding between replace vs append.
164
+ def live_entry?(id)
165
+ return false if id.nil?
166
+ @buffer.live?(id)
167
+ end
168
+
169
+ # Remove an entry. If it's the last live entry, the screen area it
170
+ # occupied is cleared and the output cursor rolls back.
171
+ def remove_entry(id)
172
+ return if id.nil?
173
+
174
+ @render_mutex.synchronize do
175
+ entry = @buffer.entry_by_id(id)
176
+ return if entry.nil? || entry.committed
177
+ # Can't remove an entry whose prefix has already scrolled into
178
+ # terminal scrollback — those rows are immutable. The visible
179
+ # suffix will roll off on its own as more output is produced.
180
+ return if (entry.committed_line_offset || 0) > 0
181
+
182
+ height = entry.height
183
+ # Check whether this entry is the tail of live entries. Only tail
184
+ # removal is cheap — mid-buffer removal would require a full
185
+ # output repaint. In practice only the progress / inline-input
186
+ # entries are removed, and they are always the tail.
187
+ is_tail = @buffer.live_entries.last&.id == id
188
+
189
+ @buffer.remove(id)
190
+ @last_append_id = nil if @last_append_id == id
191
+
192
+ unless @fullscreen_mode
193
+ if is_tail
194
+ clear_tail_rows(height)
195
+ else
196
+ # Non-tail removal: rebuild the entire output area from buffer
197
+ render_output_from_buffer
198
+ end
199
+ end
200
+
201
+ render_fixed_areas
202
+ screen.flush
203
+ end
204
+ end
205
+
206
+ # -----------------------------------------------------------------------
207
+ # Legacy shims (kept for InlineInput + other callers that don't carry ids)
208
+ # -----------------------------------------------------------------------
209
+
210
+ # Update the most recently appended entry. Prefer passing +id:+; when
211
+ # omitted the last-append id is used. +old_line_count+ is ignored
212
+ # (buffer knows the true height).
213
+ def update_last_line(content, old_line_count = nil, id: nil)
214
+ target = id || @last_append_id
215
+ replace_entry(target, content) if target
216
+ end
217
+
218
+ # Remove the most recently appended entry (or the given id).
219
+ def remove_last_line(line_count = 1, id: nil)
220
+ target = id || @last_append_id
221
+ remove_entry(target) if target
222
+ end
223
+
224
+ # -----------------------------------------------------------------------
225
+ # Paint primitives (private)
226
+ # -----------------------------------------------------------------------
227
+
228
+ # Paint fresh lines into the output area, scrolling via native \n when
229
+ # we reach the fixed area. CRUCIAL INVARIANT: every time we scroll,
230
+ # we tell the buffer "N oldest live lines just moved into scrollback"
231
+ # so they are NEVER re-painted from the buffer again. This is what
232
+ # eliminates the double-render bug.
233
+ private def paint_new_lines(lines)
234
+ max_output_row = fixed_area_start_row
235
+
236
+ lines.each do |line|
237
+ if @output_row >= max_output_row
238
+ # Scroll the terminal by emitting a real \n at the very bottom.
239
+ # That pushes the top visible row into the native scrollback
240
+ # buffer — exactly where the user will see it on scroll-up.
241
+ screen.move_cursor(screen.height - 1, 0)
242
+ print "\n"
243
+
244
+ # Tell the buffer one line of live content just left the screen.
245
+ # Committed entries become untouchable, so a later full repaint
246
+ # (resize, fixed-area height change, fullscreen exit) will NOT
247
+ # re-emit them and duplicate them in scrollback.
248
+ @buffer.commit_oldest_lines(1)
249
+
250
+ @output_row = max_output_row - 1
251
+
252
+ # The fixed area got scrolled up too — restore it. Don't trigger
253
+ # an output rebuild; the buffer's tail hasn't changed.
254
+ render_fixed_areas(skip_buffer_rerender: true)
255
+ end
256
+
257
+ screen.move_cursor(@output_row, 0)
258
+ screen.clear_line
259
+ print line
260
+ @output_row += 1
261
+ end
262
+ end
263
+
264
+ # Repaint a single entry in place after its content changed.
265
+ # Handles both grow and shrink. If the new content would overflow
266
+ # into the fixed area, we scroll up to make room (same rules as
267
+ # paint_new_lines — scrolled rows get committed to scrollback).
268
+ private def repaint_entry_in_place(entry, old_lines, new_lines)
269
+ old_n = old_lines.length
270
+ new_n = new_lines.length
271
+ return if @output_row == 0
272
+
273
+ start_row = @output_row - old_n
274
+ start_row = 0 if start_row < 0
275
+
276
+ max_output_row = fixed_area_start_row
277
+
278
+ # Grow + would overflow → scroll first
279
+ if new_n > old_n
280
+ needed_end = start_row + new_n
281
+ if needed_end > max_output_row
282
+ overflow = needed_end - max_output_row
283
+ overflow.times do
284
+ screen.move_cursor(screen.height - 1, 0)
285
+ print "\n"
286
+ @buffer.commit_oldest_lines(1)
287
+ end
288
+ start_row -= overflow
289
+ start_row = 0 if start_row < 0
290
+ @output_row = [start_row + old_n, max_output_row].min
291
+ render_fixed_areas(skip_buffer_rerender: true)
292
+ end
293
+ end
294
+
295
+ # Clear only rows whose content actually changed, then repaint
296
+ # those. Lines that are byte-identical to the previous frame stay
297
+ # untouched — avoiding the clear-then-redraw flicker that an
298
+ # always-on ticker produces 2-10x per second on slower terminals.
299
+ cur = start_row
300
+ new_lines.each_with_index do |line, i|
301
+ if i >= old_n || old_lines[i] != line
302
+ screen.move_cursor(cur, 0)
303
+ screen.clear_line
304
+ print line
305
+ end
306
+ cur += 1
307
+ end
308
+ # If content shrank, blank out the rows the old frame occupied
309
+ # below the new tail.
310
+ if new_n < old_n
311
+ (cur...(start_row + old_n)).each do |row|
312
+ screen.move_cursor(row, 0)
313
+ screen.clear_line
314
+ end
315
+ end
316
+ @output_row = start_row + new_n
317
+ end
318
+
319
+ # Clear the last N rows of the output area (used by remove_entry on tail).
320
+ private def clear_tail_rows(n)
321
+ return if n <= 0 || @output_row == 0
322
+
323
+ start_row = @output_row - n
324
+ start_row = 0 if start_row < 0
325
+
326
+ (start_row...@output_row).each do |row|
327
+ screen.move_cursor(row, 0)
328
+ screen.clear_line
329
+ end
330
+ @output_row = start_row
331
+ end
332
+
333
+ # Repaint the entire output area from the buffer's live entries.
334
+ # Only called on layout changes (resize, fixed-area height change,
335
+ # /clear, fullscreen exit) — never on a normal append path.
336
+ private def render_output_from_buffer
337
+ max_output_row = fixed_area_start_row
338
+
339
+ # Wipe the output area
340
+ (0...max_output_row).each do |row|
341
+ screen.move_cursor(row, 0)
342
+ screen.clear_line
343
+ end
344
+
345
+ # Fill from the buffer's tail (live lines only — committed lines
346
+ # are already in terminal scrollback and MUST NOT be repainted).
347
+ lines = @buffer.tail_lines(max_output_row)
348
+ @output_row = 0
349
+ lines.each do |line|
350
+ screen.move_cursor(@output_row, 0)
351
+ print line
352
+ @output_row += 1
353
+ end
354
+ end
355
+
356
+ # Wrap user content into screen-width visual lines using the existing
357
+ # ANSI-aware helper. Guarantees at least one line (possibly empty).
358
+ private def wrap_content_to_lines(content)
359
+ raw_lines = content.split("\n", -1)
360
+ wrapped = []
361
+ raw_lines.each do |rl|
362
+ wrapped.concat(wrap_long_line(rl))
363
+ end
364
+ wrapped = [""] if wrapped.empty?
365
+ wrapped
366
+ end
367
+
368
+ private def sanitize(content)
369
+ return content if content.valid_encoding?
370
+ Octo::Utils::Encoding.sanitize_utf8(content)
371
+ end
372
+
373
+ # -----------------------------------------------------------------------
374
+ # Lifecycle + layout
375
+ # -----------------------------------------------------------------------
376
+
377
+ def initialize_screen
378
+ screen.clear_screen
379
+ screen.hide_cursor
380
+ @output_row = 0
381
+ render_all
382
+ end
383
+
384
+ def cleanup_screen
385
+ @render_mutex.synchronize do
386
+ fixed_start = fixed_area_start_row
387
+ (fixed_start...screen.height).each do |row|
388
+ screen.move_cursor(row, 0)
389
+ screen.clear_line
390
+ end
391
+ screen.move_cursor([@output_row, 0].max, 0)
392
+ print "\r"
393
+ screen.show_cursor
394
+ screen.flush
395
+ end
396
+ end
397
+
398
+ # /clear: wipe output area + buffer, keep fixed area.
399
+ def clear_output
400
+ @render_mutex.synchronize do
401
+ max_row = fixed_area_start_row
402
+ (0...max_row).each do |row|
403
+ screen.move_cursor(row, 0)
404
+ screen.clear_line
405
+ end
406
+ @output_row = 0
407
+ @last_append_id = nil
408
+ @buffer.clear
409
+ render_fixed_areas
410
+ screen.flush
411
+ end
412
+ end
413
+
414
+ # Recalculate layout after input height changed. If the layout moved,
415
+ # clear the old fixed area rows and re-render at the new position.
416
+ def recalculate_layout
417
+ @render_mutex.synchronize do
418
+ old_gap_row = @gap_row
419
+ old_input_row = @input_row
420
+
421
+ calculate_layout
422
+
423
+ if @input_row != old_input_row
424
+ ([old_gap_row, 0].max...screen.height).each do |row|
425
+ screen.move_cursor(row, 0)
426
+ screen.clear_line
427
+ end
428
+
429
+ if input_area.paused?
430
+ # Input paused (InlineInput active) — fixed area shrank, so the
431
+ # cleared rows are now part of the output area. Repaint from
432
+ # buffer to fill them in.
433
+ render_output_from_buffer
434
+ else
435
+ render_fixed_areas
436
+ end
437
+ screen.flush
438
+ end
439
+ end
440
+ end
441
+
442
+ def render_all
443
+ @render_mutex.synchronize { render_all_internal }
444
+ end
445
+
446
+ def render_output
447
+ @render_mutex.synchronize do
448
+ render_fixed_areas
449
+ screen.flush
450
+ end
451
+ end
452
+
453
+ def render_input
454
+ @render_mutex.synchronize do
455
+ render_fixed_areas
456
+ screen.flush
457
+ end
458
+ end
459
+
460
+ def rerender_all
461
+ @render_mutex.synchronize do
462
+ screen.clear_screen
463
+ render_output_from_buffer
464
+ render_fixed_areas
465
+ screen.flush
466
+ end
467
+ end
468
+
469
+ # Restore cursor to input area (used after dialogs).
470
+ def restore_cursor_to_input
471
+ input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
472
+ input_area.position_cursor(input_row)
473
+ screen.show_cursor
474
+ end
475
+
476
+ # Position cursor for inline input in output area.
477
+ def position_inline_input_cursor(inline_input)
478
+ return unless inline_input
479
+ width = screen.width
480
+ wrap_row, wrap_col = inline_input.cursor_position_for_display(width)
481
+ line_count = inline_input.line_count(width)
482
+
483
+ cursor_row = @output_row - line_count + wrap_row
484
+ cursor_col = wrap_col
485
+ screen.move_cursor(cursor_row, cursor_col)
486
+ screen.flush
487
+ end
488
+
489
+ # Update todos display; recalculates layout if height changed.
490
+ def update_todos(todos)
491
+ return unless @todo_area
492
+
493
+ @render_mutex.synchronize do
494
+ old_height = @todo_area.height
495
+ old_gap_row = @gap_row
496
+
497
+ @todo_area.update(todos)
498
+ new_height = @todo_area.height
499
+
500
+ if old_height != new_height
501
+ calculate_layout
502
+ ([old_gap_row, 0].max...screen.height).each do |row|
503
+ screen.move_cursor(row, 0)
504
+ screen.clear_line
505
+ end
506
+ end
507
+
508
+ render_fixed_areas
509
+ screen.flush
510
+ end
511
+ end
512
+
513
+ # Hide todo area while preserving its data; pair with show_todos.
514
+ def hide_todos
515
+ return unless @todo_area
516
+
517
+ @render_mutex.synchronize do
518
+ old_height = @todo_area.height
519
+ old_gap_row = @gap_row
520
+
521
+ @todo_area.hide
522
+ new_height = @todo_area.height
523
+
524
+ if old_height != new_height
525
+ calculate_layout
526
+ ([old_gap_row, 0].max...screen.height).each do |row|
527
+ screen.move_cursor(row, 0)
528
+ screen.clear_line
529
+ end
530
+ end
531
+
532
+ render_fixed_areas
533
+ screen.flush
534
+ end
535
+ end
536
+
537
+ # Show todo area again after a previous hide_todos.
538
+ def show_todos
539
+ return unless @todo_area
540
+
541
+ @render_mutex.synchronize do
542
+ old_height = @todo_area.height
543
+ old_gap_row = @gap_row
544
+
545
+ @todo_area.show
546
+ new_height = @todo_area.height
547
+
548
+ if old_height != new_height
549
+ calculate_layout
550
+ ([old_gap_row, 0].max...screen.height).each do |row|
551
+ screen.move_cursor(row, 0)
552
+ screen.clear_line
553
+ end
554
+ end
555
+
556
+ render_fixed_areas
557
+ screen.flush
558
+ end
559
+ end
560
+
561
+
562
+
563
+ # -----------------------------------------------------------------------
564
+ # Fixed area (gap + todo + input) rendering
565
+ # -----------------------------------------------------------------------
566
+
567
+ # Repaint gap + todo + input at the bottom of the screen.
568
+ #
569
+ # @param skip_buffer_rerender [Boolean] When true, skip repainting the
570
+ # output area from the buffer even if the fixed-area height changed.
571
+ # Used by the scroll path in paint_new_lines — the caller has just
572
+ # written the correct content directly; a full buffer repaint would
573
+ # duplicate it in terminal scrollback.
574
+ def render_fixed_areas(skip_buffer_rerender: false)
575
+ # When input is paused (InlineInput active), the "input area" is
576
+ # rendered inline with output. Nothing to paint down here.
577
+ return if input_area.paused?
578
+ return if @fullscreen_mode
579
+
580
+ current_fixed_height = fixed_area_height
581
+ start_row = fixed_area_start_row
582
+ gap_row = start_row
583
+ todo_row = gap_row + 1
584
+
585
+ # Fixed-area height changed (e.g. multi-line input appeared or
586
+ # command-suggestions popped) → repaint the output from buffer so
587
+ # nothing is hidden.
588
+ if !skip_buffer_rerender &&
589
+ @last_fixed_area_height > 0 &&
590
+ @last_fixed_area_height != current_fixed_height
591
+ render_output_from_buffer
592
+ end
593
+ @last_fixed_area_height = current_fixed_height
594
+
595
+ # gap line
596
+ screen.move_cursor(gap_row, 0)
597
+ screen.clear_line
598
+
599
+ # todo
600
+ @todo_area.render(start_row: todo_row) if @todo_area&.visible?
601
+
602
+ # input (renders its own visual cursor)
603
+ input_row = todo_row + (@todo_area&.height || 0)
604
+ input_area.render(start_row: input_row, width: screen.width)
605
+ end
606
+
607
+ private def render_all_internal
608
+ render_fixed_areas
609
+ screen.flush
610
+ end
611
+
612
+ # Legacy no-ops — terminal handles native scroll natively.
613
+ def scroll_output_up(_lines = 1); end
614
+ def scroll_output_down(_lines = 1); end
615
+
616
+
617
+
618
+ # -----------------------------------------------------------------------
619
+ # Wrapping helpers (ANSI-aware, East-Asian-width aware)
620
+ # -----------------------------------------------------------------------
621
+
622
+ # Wrap a long line into multiple lines based on terminal width.
623
+ # Considers display width of multi-byte characters (e.g., Chinese characters).
624
+ def wrap_long_line(line)
625
+ return [""] if line.nil? || line.empty?
626
+
627
+ max_width = screen.width
628
+ return [line] if max_width <= 0
629
+
630
+ # Strip ANSI codes for width calculation
631
+ visible_line = line.gsub(/\e\[[0-9;]*m/, '')
632
+
633
+ display_width = calculate_display_width(visible_line)
634
+ return [line] if display_width <= max_width
635
+
636
+ wrapped = []
637
+ current_line = ""
638
+ current_width = 0
639
+ ansi_codes = []
640
+
641
+ segments = line.split(/(\e\[[0-9;]*m)/)
642
+
643
+ segments.each do |segment|
644
+ if segment =~ /^\e\[[0-9;]*m$/
645
+ ansi_codes << segment
646
+ current_line += segment
647
+ else
648
+ segment.each_char do |char|
649
+ char_width = char_display_width(char)
650
+ if current_width + char_width > max_width && !current_line.empty?
651
+ wrapped << current_line
652
+ current_line = ansi_codes.join
653
+ current_width = 0
654
+ end
655
+ current_line += char
656
+ current_width += char_width
657
+ end
658
+ end
659
+ end
660
+
661
+ wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join
662
+ wrapped.empty? ? [""] : wrapped
663
+ end
664
+
665
+ def char_display_width(char)
666
+ code = char.ord
667
+ if (code >= 0x1100 && code <= 0x115F) ||
668
+ (code >= 0x2329 && code <= 0x232A) ||
669
+ (code >= 0x2E80 && code <= 0x303E) ||
670
+ (code >= 0x3040 && code <= 0xA4CF) ||
671
+ (code >= 0xAC00 && code <= 0xD7A3) ||
672
+ (code >= 0xF900 && code <= 0xFAFF) ||
673
+ (code >= 0xFE10 && code <= 0xFE19) ||
674
+ (code >= 0xFE30 && code <= 0xFE6F) ||
675
+ (code >= 0xFF00 && code <= 0xFF60) ||
676
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
677
+ (code >= 0x1F300 && code <= 0x1F9FF) ||
678
+ (code >= 0x20000 && code <= 0x2FFFD) ||
679
+ (code >= 0x30000 && code <= 0x3FFFD)
680
+ 2
681
+ else
682
+ 1
683
+ end
684
+ end
685
+
686
+ def calculate_display_width(text)
687
+ width = 0
688
+ text.each_char { |c| width += char_display_width(c) }
689
+ width
690
+ end
691
+
692
+ # -----------------------------------------------------------------------
693
+ # Resize handling
694
+ # -----------------------------------------------------------------------
695
+
696
+ private def handle_resize
697
+ old_height = screen.height
698
+ old_width = screen.width
699
+
700
+ screen.update_dimensions
701
+ calculate_layout
702
+
703
+ shrinking = screen.height < old_height || screen.width < old_width
704
+ screen.clear_screen(mode: shrinking ? :reset : :current)
705
+
706
+ # Repaint from buffer — only live (uncommitted) lines, which is
707
+ # exactly what we want: committed content already sits in the
708
+ # native scrollback above.
709
+ render_output_from_buffer
710
+
711
+ # Sync so render_fixed_areas won't think height changed and
712
+ # trigger a second repaint.
713
+ @last_fixed_area_height = fixed_area_height
714
+ render_fixed_areas
715
+ screen.flush
716
+ end
717
+
718
+ private def setup_resize_handler
719
+ Signal.trap("WINCH") { @resize_pending = true }
720
+ rescue ArgumentError => e
721
+ warn "WINCH signal already trapped: #{e.message}"
722
+ end
723
+
724
+ def process_pending_resize
725
+ return unless @resize_pending
726
+ @resize_pending = false
727
+ handle_resize_safely
728
+ end
729
+
730
+ private def handle_resize_safely
731
+ @render_mutex.synchronize { handle_resize }
732
+ rescue => e
733
+ warn "Resize error: #{e.message}"
734
+ warn e.backtrace.first(5).join("\n") if e.backtrace
735
+ end
736
+
737
+ # -----------------------------------------------------------------------
738
+ # Fullscreen (alternate screen buffer)
739
+ # -----------------------------------------------------------------------
740
+
741
+ def fullscreen_mode?
742
+ @fullscreen_mode
743
+ end
744
+
745
+ def enter_fullscreen(lines, hint: "Press Ctrl+O to return")
746
+ @render_mutex.synchronize do
747
+ return if @fullscreen_mode
748
+ @fullscreen_mode = true
749
+ @fullscreen_hint = hint
750
+
751
+ # Switch to alternate screen, clear it, position top-left.
752
+ print "\e[?1049h\e[2J\e[H"
753
+ $stdout.flush
754
+ render_fullscreen_content(lines)
755
+ end
756
+ end
757
+
758
+ def refresh_fullscreen(lines)
759
+ @render_mutex.synchronize do
760
+ return unless @fullscreen_mode
761
+ print "\e[2J\e[H"
762
+ render_fullscreen_content(lines)
763
+ end
764
+ end
765
+
766
+ def exit_fullscreen
767
+ @render_mutex.synchronize do
768
+ return unless @fullscreen_mode
769
+ @fullscreen_mode = false
770
+ @fullscreen_hint = nil
771
+ print "\e[?1049l"
772
+ $stdout.flush
773
+ end
774
+ end
775
+
776
+ def restore_screen
777
+ @render_mutex.synchronize do
778
+ screen.clear_screen
779
+ screen.hide_cursor
780
+ render_all_internal
781
+ end
782
+ end
783
+
784
+ private def render_fullscreen_content(lines)
785
+ term_height = screen.height
786
+ term_width = screen.width
787
+
788
+ content_rows = term_height - 1
789
+ display_lines = lines.first(content_rows)
790
+
791
+ display_lines.each do |line|
792
+ visible = line.chomp.gsub(/\e\[[0-9;]*m/, "")
793
+ padding = [term_width - visible.length, 0].max
794
+ print line.chomp + (" " * padding) + "\r\n"
795
+ end
796
+
797
+ blank_row = " " * term_width
798
+ (display_lines.length...content_rows).each { print blank_row + "\r\n" }
799
+
800
+ hint_text = "\e[36m#{@fullscreen_hint}\e[0m"
801
+ print "\e[#{term_height};1H#{hint_text}\e[0K"
802
+ $stdout.flush
803
+ end
804
+
805
+ end
806
+ end
807
+ end