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,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utils/limit_stack"
4
+
5
+ module Octo
6
+ module UI2
7
+ # OutputBuffer manages the logical sequence of rendered output lines.
8
+ #
9
+ # It replaces the scattered state that used to live across
10
+ # LayoutManager (@output_buffer + @output_row) and UIController
11
+ # (@progress_message / "last line" assumptions).
12
+ #
13
+ # Core concepts:
14
+ #
15
+ # - Every append returns an +id+. Callers can later +replace(id, ...)+
16
+ # or +remove(id)+ that exact entry without relying on "the last line".
17
+ # - Each entry tracks whether it has been "committed" to the terminal
18
+ # scrollback (i.e. scrolled off the top of the visible window by a
19
+ # native terminal \n). Committed entries are NEVER re-drawn from the
20
+ # buffer again — this is what prevents the classic "scroll up shows
21
+ # a duplicated line" bug.
22
+ # - Entries may contain multi-line content (already wrapped). Each entry
23
+ # stores its visual line count so the renderer can compute exact rows
24
+ # to clear when replacing or removing.
25
+ #
26
+ # The buffer itself does NOT talk to the terminal. It is a pure data
27
+ # structure; a renderer (LayoutManager) consumes it through the
28
+ # snapshot APIs: +visible_entries+, +entry_by_id+, +tail_lines+.
29
+ class OutputBuffer
30
+ # A single logical output entry.
31
+ #
32
+ # @!attribute id [Integer] Monotonic id, unique within the buffer
33
+ # @!attribute lines [Array<String>] Rendered (already-wrapped) visual lines
34
+ # @!attribute kind [Symbol] :text | :progress | :system (hint for renderer)
35
+ # @!attribute committed [Boolean] True once pushed into terminal scrollback
36
+ Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do
37
+ # Visual row count this entry currently OCCUPIES on screen. Once a
38
+ # prefix of the entry's lines has been pushed into scrollback by
39
+ # a scroll+partial-commit, those prefix rows are no longer on
40
+ # screen — so height drops accordingly. When +committed+ flips to
41
+ # true the entry is considered fully off-screen and height is 0.
42
+ def height
43
+ return 0 if committed
44
+ lines.length - (committed_line_offset || 0)
45
+ end
46
+
47
+ # The currently on-screen lines of this entry (lines that haven't
48
+ # been pushed to scrollback yet). Returns [] once fully committed.
49
+ def visible_lines
50
+ return [] if committed
51
+ off = committed_line_offset || 0
52
+ off.zero? ? lines : lines[off..] || []
53
+ end
54
+
55
+ def to_s
56
+ lines.join("\n")
57
+ end
58
+ end
59
+
60
+ DEFAULT_MAX_ENTRIES = 2000
61
+
62
+ attr_reader :entries
63
+
64
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
65
+ @entries = [] # Array<Entry> in insertion order
66
+ @index = {} # id => Entry (fast lookup)
67
+ @next_id = 1
68
+ @max_entries = max_entries
69
+ @mutex = Mutex.new
70
+ # Monotonic counter incremented every time the buffer changes.
71
+ # Renderers can compare this against a saved version to decide
72
+ # whether their cached screen image is still valid.
73
+ @version = 0
74
+ end
75
+
76
+ # Append a new entry. +content+ may be a String (may include \n) or
77
+ # an Array<String> of already-split lines.
78
+ #
79
+ # @param content [String, Array<String>]
80
+ # @param kind [Symbol] :text (default), :progress, :system
81
+ # @return [Integer] id of the newly created entry
82
+ def append(content, kind: :text)
83
+ @mutex.synchronize do
84
+ lines = normalize_lines(content)
85
+ entry = Entry.new(id: next_id!, lines: lines, kind: kind, committed: false, committed_line_offset: 0)
86
+ @entries << entry
87
+ @index[entry.id] = entry
88
+ trim_if_needed
89
+ bump_version
90
+ entry.id
91
+ end
92
+ end
93
+
94
+ # Replace an existing entry's content. If the id no longer exists
95
+ # (e.g. the entry was trimmed or already committed and recycled),
96
+ # this is a no-op and returns nil.
97
+ #
98
+ # Replacing a committed entry is silently ignored — committed content
99
+ # lives in terminal scrollback and cannot be edited in place. Same
100
+ # for an entry whose prefix has been partial-committed: the prefix
101
+ # is already in scrollback and replacing the entry would either
102
+ # strand those lines (if shorter) or duplicate them (if longer).
103
+ #
104
+ # @param id [Integer]
105
+ # @param content [String, Array<String>]
106
+ # @return [Integer, nil] Old visible height if replaced, nil if no-op
107
+ def replace(id, content)
108
+ @mutex.synchronize do
109
+ entry = @index[id]
110
+ return nil unless entry
111
+ return nil if entry.committed
112
+ return nil if (entry.committed_line_offset || 0) > 0
113
+
114
+ old_height = entry.height
115
+ entry.lines = normalize_lines(content)
116
+ bump_version
117
+ old_height
118
+ end
119
+ end
120
+
121
+ # Remove an entry. Committed entries cannot be removed (they are in
122
+ # terminal scrollback). Partially-committed entries also cannot be
123
+ # removed — their prefix is frozen in scrollback. Returns the
124
+ # removed Entry, or nil if no-op.
125
+ #
126
+ # @param id [Integer]
127
+ # @return [Entry, nil]
128
+ def remove(id)
129
+ @mutex.synchronize do
130
+ entry = @index[id]
131
+ return nil unless entry
132
+ return nil if entry.committed
133
+ return nil if (entry.committed_line_offset || 0) > 0
134
+
135
+ @entries.delete(entry)
136
+ @index.delete(id)
137
+ bump_version
138
+ entry
139
+ end
140
+ end
141
+
142
+ # Mark an entry (and every older live entry) as committed to terminal
143
+ # scrollback. Called by the renderer after it has emitted a native \n
144
+ # that scrolled the top-of-screen row off into scrollback.
145
+ #
146
+ # Committing always flows from oldest → newest: if entry X is
147
+ # committed, every entry older than X must also be committed, because
148
+ # they have already scrolled past X on the screen.
149
+ #
150
+ # @param id [Integer]
151
+ def commit_through(id)
152
+ @mutex.synchronize do
153
+ committed_any = false
154
+ @entries.each do |e|
155
+ break if e.id > id
156
+ unless e.committed
157
+ e.committed = true
158
+ committed_any = true
159
+ end
160
+ end
161
+ bump_version if committed_any
162
+ end
163
+ end
164
+
165
+ # Commit the oldest N VISUAL rows. Used when the renderer scrolls N
166
+ # lines off the top via native \n. Commits are precise at the visual
167
+ # row granularity (even mid-entry): if the oldest live entry is
168
+ # multi-line and only its prefix has scrolled off, that prefix is
169
+ # recorded in +committed_line_offset+ and only the still-visible
170
+ # suffix remains eligible for future repaints.
171
+ #
172
+ # This is the critical invariant for preventing the "scroll up to
173
+ # see a line already in scrollback, then render_output_from_buffer
174
+ # repaints it again on screen" duplicate-output regression: every
175
+ # visual row that went into terminal scrollback MUST be removed
176
+ # from the buffer's pool of repaintable live rows, regardless of
177
+ # whether it sat alone in a 1-line entry or at the top of a 10-line
178
+ # entry.
179
+ #
180
+ # @param line_count [Integer] Number of visual lines pushed to scrollback
181
+ # @return [Integer] Number of entries NEWLY marked fully committed
182
+ # (partial commits on an entry do NOT count toward this total —
183
+ # callers use the return value only as a debug hint, not for row
184
+ # bookkeeping).
185
+ def commit_oldest_lines(line_count)
186
+ return 0 if line_count <= 0
187
+
188
+ @mutex.synchronize do
189
+ remaining = line_count
190
+ committed = 0
191
+ changed = false
192
+ @entries.each do |e|
193
+ break if remaining <= 0
194
+ next if e.committed
195
+
196
+ h = e.height
197
+ if h <= remaining
198
+ # Full scroll-off of this entry's remaining visible rows.
199
+ e.committed = true
200
+ e.committed_line_offset = e.lines.length # normalize
201
+ remaining -= h
202
+ committed += 1
203
+ changed = true
204
+ else
205
+ # Partial scroll: record the new offset and stop (there are
206
+ # still visible rows of this entry on screen).
207
+ e.committed_line_offset = (e.committed_line_offset || 0) + remaining
208
+ remaining = 0
209
+ changed = true
210
+ break
211
+ end
212
+ end
213
+ bump_version if changed
214
+ committed
215
+ end
216
+ end
217
+
218
+ # Entries that are still live (not committed). These are candidates
219
+ # for re-rendering into the visible output area.
220
+ #
221
+ # @return [Array<Entry>]
222
+ def live_entries
223
+ @mutex.synchronize { @entries.reject(&:committed).dup }
224
+ end
225
+
226
+ # The last N *visual lines* across live entries, preserving entry
227
+ # boundaries. Returns an Array<String> suitable for row-by-row
228
+ # painting. If the last live entry is taller than +n+, only its last
229
+ # +n+ lines are returned.
230
+ #
231
+ # @param n [Integer]
232
+ # @return [Array<String>]
233
+ def tail_lines(n)
234
+ return [] if n <= 0
235
+
236
+ @mutex.synchronize do
237
+ collected = []
238
+ @entries.reverse_each do |e|
239
+ break if collected.length >= n
240
+ next if e.committed
241
+
242
+ # The entry's still-visible lines (excluding any prefix already
243
+ # committed to scrollback via a partial commit).
244
+ vis = e.visible_lines
245
+ next if vis.empty?
246
+
247
+ # Prepend the entry's visible lines in order
248
+ remaining = n - collected.length
249
+ if vis.length <= remaining
250
+ collected = vis + collected
251
+ else
252
+ collected = vis.last(remaining) + collected
253
+ break
254
+ end
255
+ end
256
+ collected
257
+ end
258
+ end
259
+
260
+ # Look up an entry by id.
261
+ # @param id [Integer]
262
+ # @return [Entry, nil]
263
+ def entry_by_id(id)
264
+ @mutex.synchronize { @index[id] }
265
+ end
266
+
267
+ # Does this id still refer to a live, editable entry?
268
+ # @param id [Integer]
269
+ def live?(id)
270
+ @mutex.synchronize do
271
+ e = @index[id]
272
+ !!(e && !e.committed)
273
+ end
274
+ end
275
+
276
+ # Does this id refer to an entry that can still be replaced or
277
+ # removed in place? A partially-committed entry (prefix already in
278
+ # scrollback via a scroll) is NOT editable — its visible suffix is
279
+ # frozen until it either fully commits or (rare) a full repaint
280
+ # rewrites the screen.
281
+ #
282
+ # @param id [Integer]
283
+ def fully_editable?(id)
284
+ @mutex.synchronize do
285
+ e = @index[id]
286
+ !!(e && !e.committed && (e.committed_line_offset || 0) == 0)
287
+ end
288
+ end
289
+
290
+ # Total number of entries (committed + live) currently tracked.
291
+ def size
292
+ @mutex.synchronize { @entries.size }
293
+ end
294
+
295
+ # Number of live entries.
296
+ def live_size
297
+ @mutex.synchronize { @entries.count { |e| !e.committed } }
298
+ end
299
+
300
+ # Total visual lines across live entries.
301
+ def live_line_count
302
+ @mutex.synchronize { @entries.sum { |e| e.committed ? 0 : e.height } }
303
+ end
304
+
305
+ # Monotonic version (incremented on every mutation).
306
+ def version
307
+ @version
308
+ end
309
+
310
+ # Clear everything. Used by /clear command.
311
+ def clear
312
+ @mutex.synchronize do
313
+ @entries.clear
314
+ @index.clear
315
+ bump_version
316
+ end
317
+ end
318
+
319
+ # --- helpers ----------------------------------------------------------
320
+
321
+ private def next_id!
322
+ id = @next_id
323
+ @next_id += 1
324
+ id
325
+ end
326
+
327
+ private def bump_version
328
+ @version += 1
329
+ end
330
+
331
+ # Drop the oldest entries when the buffer grows past the cap. This is
332
+ # a soft safety net — in practice live entries stay small because
333
+ # write_output_line commits them to scrollback as they scroll off.
334
+ private def trim_if_needed
335
+ while @entries.size > @max_entries
336
+ dropped = @entries.shift
337
+ @index.delete(dropped.id)
338
+ end
339
+ end
340
+
341
+ # Normalize input into an array of visual lines (no trailing \n kept).
342
+ # Empty strings are preserved so callers can explicitly append blank
343
+ # rows.
344
+ #
345
+ # Rules:
346
+ # - nil → [""]
347
+ # - Array<String> → deep copy (caller has pre-split)
348
+ # - "hello" → ["hello"]
349
+ # - "a\nb" → ["a", "b"]
350
+ # - "a\n" → ["a"] (trailing newline is not a new line)
351
+ # - "a\n\n" → ["a", ""] (explicit blank line preserved)
352
+ # - "" → [""]
353
+ private def normalize_lines(content)
354
+ case content
355
+ when nil
356
+ [""]
357
+ when Array
358
+ content.map(&:to_s)
359
+ else
360
+ str = content.to_s
361
+ return [""] if str.empty?
362
+ # Strip a single trailing newline so "a\n" → ["a"], but keep
363
+ # explicit blank lines ("a\n\n" → ["a", ""]).
364
+ str = str.chomp("\n")
365
+ str.split("\n", -1)
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end