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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_component"
4
+
5
+ module Octo
6
+ module UI2
7
+ module Components
8
+ # MessageComponent renders user and assistant messages
9
+ class MessageComponent < BaseComponent
10
+ # Render a message
11
+ # @param data [Hash] Message data
12
+ # - :role [String] "user" or "assistant"
13
+ # - :content [String] Message content
14
+ # - :timestamp [Time, nil] Optional timestamp
15
+ # - :files [Array<Hash>] Optional file hashes (for user messages)
16
+ # - :prefix_newline [Boolean] Whether to add newline before message (for system messages)
17
+ # @return [String] Rendered message
18
+ def render(data)
19
+ role = data[:role]
20
+ content = data[:content]
21
+ timestamp = data[:timestamp]
22
+ files = data[:files] || []
23
+ prefix_newline = data.fetch(:prefix_newline, true)
24
+
25
+ case role
26
+ when "user"
27
+ render_user_message(content, timestamp, files)
28
+ when "assistant"
29
+ render_assistant_message(content, timestamp)
30
+ else
31
+ render_system_message(content, timestamp, prefix_newline)
32
+ end
33
+ end
34
+
35
+
36
+ # Render user message
37
+ # @param content [String] Message content
38
+ # @param timestamp [Time, nil] Optional timestamp
39
+ # @param files [Array<Hash>] Optional file hashes { name:, mime_type:, ... }
40
+ # @return [String] Rendered message
41
+ def render_user_message(content, timestamp = nil, files = [])
42
+ symbol = format_symbol(:user)
43
+ text = format_text(content, :user)
44
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
45
+
46
+ result = "\n#{symbol} #{text} #{time_str}".rstrip
47
+
48
+ # Append file attachment info if present
49
+ if files && files.any?
50
+ files.each_with_index do |f, idx|
51
+ filename = f[:name] || f["name"] || "file"
52
+ result += "\n" + @pastel.dim(" [File #{idx + 1}] #{filename}")
53
+ end
54
+ end
55
+
56
+ result
57
+ end
58
+
59
+ private def format_filesize(bytes)
60
+ if bytes < 1024
61
+ "#{bytes}b"
62
+ elsif bytes < 1024 * 1024
63
+ "#{(bytes / 1024.0).round(1)}kb"
64
+ else
65
+ "#{(bytes / (1024.0 * 1024)).round(1)}mb"
66
+ end
67
+ end
68
+
69
+ # Render assistant message
70
+ # @param content [String] Message content
71
+ # @param timestamp [Time, nil] Optional timestamp
72
+ # @return [String] Rendered message
73
+ def render_assistant_message(content, timestamp = nil)
74
+ return "" if content.nil? || content.empty?
75
+
76
+ symbol = format_symbol(:assistant)
77
+ text = format_text(content, :assistant)
78
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
79
+
80
+ "\n#{symbol} #{text} #{time_str}".rstrip
81
+ end
82
+
83
+ # Render system message
84
+ # @param content [String] Message content
85
+ # @param timestamp [Time, nil] Optional timestamp
86
+ # @param prefix_newline [Boolean] Whether to add newline before message
87
+ # @return [String] Rendered message
88
+ private def render_system_message(content, timestamp = nil, prefix_newline = true)
89
+ symbol = format_symbol(:info)
90
+ text = format_text(content, :info)
91
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
92
+
93
+ prefix = prefix_newline ? "\n" : ""
94
+ "#{prefix}#{symbol} #{text} #{time_str}".rstrip
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'tty-prompt'
5
+ require_relative 'base_component'
6
+
7
+ module Octo
8
+ module UI2
9
+ module Components
10
+ # ModalComponent - Displays a centered modal dialog with form fields
11
+ class ModalComponent < BaseComponent
12
+ attr_reader :width, :height
13
+
14
+ def initialize
15
+ super
16
+ @width = 70
17
+ @height = 16
18
+ @title = ""
19
+ @fields = []
20
+ @choices = []
21
+ @values = {}
22
+ @selected_index = 0
23
+ @mode = :form
24
+ end
25
+
26
+ # Configure and show the modal
27
+ # @param title [String] Modal title
28
+ # @param fields [Array<Hash>] Field definitions (for form mode)
29
+ # @param choices [Array<Hash>] Choice definitions (for menu mode)
30
+ # Example: [{ name: "Option 1", value: :opt1 }, { name: "---", disabled: true }]
31
+ # @param validator [Proc, nil] Optional validation callback that receives values hash
32
+ # Should return { success: true } or { success: false, error: "message" }
33
+ # @param on_close [Proc, nil] Optional callback to execute when modal closes (e.g., to re-render screen)
34
+ # @return [Hash, nil] Hash of field values or selected value, or nil if cancelled
35
+ def show(title:, fields: nil, choices: nil, validator: nil, on_close: nil)
36
+ @title = title
37
+ @mode = choices ? :menu : :form
38
+ @fields = fields || []
39
+ @choices = choices || []
40
+ @values = {}
41
+ @error_message = nil
42
+ @selected_index = 0
43
+
44
+ # For menu mode, find first non-disabled choice
45
+ if @mode == :menu
46
+ @selected_index = @choices.index { |c| !c[:disabled] } || 0
47
+ end
48
+
49
+ # Adjust height based on mode
50
+ if @mode == :menu
51
+ visible_items = [@choices.length, 15].min
52
+ @height = visible_items + 4 # +4 for title, borders, and instructions
53
+ else
54
+ # Form mode - adjust height based on number of fields
55
+ # Each field takes 2 rows (label + input)
56
+ # +3 for title and top border
57
+ # +5 for error message area, buttons, and bottom border
58
+ @height = (@fields.length * 2) + 3 + 5
59
+ end
60
+
61
+ # Get terminal size
62
+ term_height, term_width = IO.console.winsize
63
+
64
+ # Calculate modal position (centered)
65
+ start_row = [(term_height - @height) / 2, 1].max
66
+ start_col = [(term_width - @width) / 2, 1].max
67
+
68
+ begin
69
+ if @mode == :menu
70
+ # Menu mode - show choices and handle selection
71
+ return show_menu_mode(start_row, start_col)
72
+ else
73
+ # Form mode - collect field inputs
74
+ return show_form_mode(start_row, start_col, validator)
75
+ end
76
+ ensure
77
+ # Clear modal area
78
+ clear_modal(start_row, start_col)
79
+ # Call on_close callback if provided (e.g., to re-render screen)
80
+ on_close&.call
81
+ end
82
+ end
83
+
84
+ # Render method (required by BaseComponent, not used for modal)
85
+ def render(data)
86
+ # Modal uses interactive show() method instead
87
+ ""
88
+ end
89
+
90
+
91
+ # Show menu mode
92
+ private def show_menu_mode(start_row, start_col)
93
+ loop do
94
+ # Draw menu
95
+ draw_modal(start_row, start_col)
96
+ draw_menu_choices(start_row + 2, start_col)
97
+ draw_menu_instructions(start_row + @height - 2, start_col)
98
+
99
+ # Read input
100
+ char = STDIN.getch
101
+
102
+ case char
103
+ when "\r", "\n" # Enter - select current choice
104
+ selected = @choices[@selected_index]
105
+ return nil if selected.nil? || selected[:disabled]
106
+ print "\e[?25l" # Hide cursor
107
+ return selected[:value]
108
+ when "\e" # Escape sequence
109
+ seq = STDIN.read_nonblock(2) rescue ''
110
+ if seq.empty?
111
+ # Just Esc key - cancel
112
+ print "\e[?25l"
113
+ return nil
114
+ elsif seq == '[A' # Up arrow
115
+ move_menu_selection(-1)
116
+ elsif seq == '[B' # Down arrow
117
+ move_menu_selection(1)
118
+ end
119
+ when "\u0003" # Ctrl+C
120
+ print "\e[?25l"
121
+ return nil
122
+ when 'k', 'K' # Vim-style up
123
+ move_menu_selection(-1)
124
+ when 'j', 'J' # Vim-style down
125
+ move_menu_selection(1)
126
+ when 'q', 'Q' # Quit
127
+ print "\e[?25l"
128
+ return nil
129
+ end
130
+ end
131
+ end
132
+
133
+ # Show form mode
134
+ private def show_form_mode(start_row, start_col, validator)
135
+ loop do
136
+ # Draw modal background and border
137
+ draw_modal(start_row, start_col)
138
+
139
+ # Draw error message if present
140
+ if @error_message
141
+ draw_error_message(start_row + @height - 5, start_col)
142
+ end
143
+
144
+ # Draw instructions
145
+ draw_buttons(start_row + @height - 3, start_col)
146
+
147
+ # Collect input for each field
148
+ current_row = start_row + 3
149
+ @fields.each do |field|
150
+ # Use previously entered value as default if validation failed
151
+ field_with_previous = field.dup
152
+ field_with_previous[:default] = @values[field[:name]] || field[:default]
153
+
154
+ value = collect_field_input(field_with_previous, current_row, start_col)
155
+ if value == :cancelled
156
+ print "\e[?25l" # Hide cursor
157
+ return nil # User pressed Esc
158
+ end
159
+ @values[field[:name]] = value
160
+ current_row += 2
161
+ end
162
+
163
+ # All fields collected - validate if validator provided
164
+ if validator
165
+ # Show "Testing..." message
166
+ testing_row = start_row + @height - 5
167
+ testing_col = start_col + 3
168
+ print "\e[#{testing_row};#{testing_col}H\e[K"
169
+ print "\e[#{testing_row + 1};#{testing_col}H\e[K"
170
+ print @pastel.cyan("⏳ Testing connection...")
171
+ STDOUT.flush
172
+
173
+ validation_result = validator.call(@values)
174
+
175
+ # Clear testing messages
176
+ print "\e[#{testing_row};#{testing_col}H\e[K"
177
+ print "\e[#{testing_row + 1};#{testing_col}H\e[K"
178
+
179
+ if validation_result[:success]
180
+ # Validation passed - hide cursor and return values
181
+ print "\e[?25l"
182
+ return @values
183
+ else
184
+ # Validation failed - show error and loop again to let user correct input
185
+ @error_message = validation_result[:error] || "Validation failed"
186
+ # Don't clear modal - just loop again to redraw with error message
187
+ # This prevents the modal from flickering
188
+ end
189
+ else
190
+ # No validator - return immediately
191
+ print "\e[?25l"
192
+ return @values
193
+ end
194
+ end
195
+ end
196
+
197
+ # Draw the modal background and border
198
+ private def draw_modal(start_row, start_col)
199
+ # Use theme colors - cyan for border, bright_cyan for title
200
+ reset = "\e[0m"
201
+
202
+ # Draw box with border
203
+ @height.times do |i|
204
+ print "\e[#{start_row + i};#{start_col}H"
205
+
206
+ if i == 0
207
+ # Top border with title
208
+ title_text = " #{@title} "
209
+ padding = (@width - title_text.length - 2) / 2
210
+ remaining = @width - padding - title_text.length - 2
211
+ border_line = @pastel.cyan("┌" + "─" * padding)
212
+ title_part = @pastel.bright_cyan(title_text)
213
+ border_rest = @pastel.cyan("─" * remaining + "┐")
214
+ print border_line + title_part + border_rest
215
+ elsif i == @height - 1
216
+ # Bottom border
217
+ print @pastel.cyan("└" + "─" * (@width - 2) + "┘")
218
+ else
219
+ # Side borders with background
220
+ left_border = @pastel.cyan("│")
221
+ right_border = @pastel.cyan("│")
222
+ print left_border + " " * (@width - 2) + right_border
223
+ end
224
+ end
225
+ end
226
+
227
+ # Collect input for a single field
228
+ private def collect_field_input(field, row, col)
229
+ require 'io/console'
230
+
231
+ label_text = @pastel.white(field[:label])
232
+
233
+ # Draw field label
234
+ print "\e[#{row};#{col + 2}H#{label_text}"
235
+
236
+ # Input field position
237
+ input_row = row + 1
238
+ input_col = col + 4
239
+ input_width = @width - 8
240
+
241
+ # Initialize input buffer with default value
242
+ buffer = field[:default].to_s.dup
243
+ cursor_pos = buffer.length
244
+ placeholder = "Press Enter to keep current"
245
+
246
+ # Show cursor for input
247
+ print "\e[?25h"
248
+
249
+ loop do
250
+ # Draw input field with cursor or placeholder
251
+ if buffer.empty?
252
+ # Show placeholder in dim gray
253
+ display_text = @pastel.dim(placeholder)
254
+ elsif field[:mask]
255
+ # Show masked input - limit display length to prevent overflow
256
+ mask_length = [buffer.length, input_width].min
257
+ display_text = @pastel.cyan('*' * mask_length)
258
+ else
259
+ # Show normal input
260
+ display_text = @pastel.cyan(buffer)
261
+ end
262
+
263
+ # Clear line and draw input
264
+ print "\e[#{input_row};#{input_col}H\e[K"
265
+ print display_text
266
+
267
+ # Position cursor and ensure it's visible
268
+ visible_cursor_pos = [cursor_pos, input_width - 1].min
269
+ print "\e[#{input_row};#{input_col + visible_cursor_pos}H"
270
+ STDOUT.flush
271
+
272
+ # Read character
273
+ char = STDIN.getch
274
+
275
+ case char
276
+ when "\r", "\n" # Enter - confirm input
277
+ # Clear placeholder if input is empty
278
+ if buffer.empty?
279
+ print "\e[#{input_row};#{input_col}H\e[K"
280
+ end
281
+ # Don't hide cursor here - next field will reuse it
282
+ return buffer
283
+ when "\e" # Escape sequence
284
+ seq = STDIN.read_nonblock(2) rescue ''
285
+ if seq.empty?
286
+ # Just Esc key - cancel (hide cursor when cancelling)
287
+ print "\e[?25l"
288
+ return :cancelled
289
+ elsif seq == '[C' # Right arrow
290
+ cursor_pos = [cursor_pos + 1, buffer.length].min
291
+ elsif seq == '[D' # Left arrow
292
+ cursor_pos = [cursor_pos - 1, 0].max
293
+ elsif seq == '[H' # Home
294
+ cursor_pos = 0
295
+ elsif seq == '[F' # End
296
+ cursor_pos = buffer.length
297
+ end
298
+ when "\u007F", "\b" # Backspace
299
+ if cursor_pos > 0
300
+ buffer[cursor_pos - 1] = ''
301
+ cursor_pos -= 1
302
+ end
303
+ when "\u0003" # Ctrl+C (hide cursor when cancelling)
304
+ print "\e[?25l"
305
+ return :cancelled
306
+ when "\u0015" # Ctrl+U - clear line
307
+ buffer = ''
308
+ cursor_pos = 0
309
+ else
310
+ # Regular character input
311
+ if char.ord >= 32 && char.ord < 127
312
+ buffer.insert(cursor_pos, char)
313
+ cursor_pos += 1
314
+ end
315
+ end
316
+ end
317
+ end
318
+
319
+ # Draw error message
320
+ private def draw_error_message(row, col)
321
+ return if @error_message.nil? || @error_message.empty?
322
+
323
+ max_width = @width - 6
324
+ # Truncate error message if too long
325
+ error_text = @error_message.length > max_width ? @error_message[0..max_width-4] + "..." : @error_message
326
+ error_col = col + 3
327
+
328
+ # Clear the line first to prevent leftover characters
329
+ print "\e[#{row};#{error_col}H\e[K"
330
+ formatted = @pastel.red("⚠ #{error_text}")
331
+ print formatted
332
+ end
333
+
334
+ # Draw confirmation buttons
335
+ private def draw_buttons(row, col)
336
+ # Show instructions at bottom of modal
337
+ buttons_text = "Press Enter after each field • Press Esc to cancel"
338
+ button_col = col + (@width - buttons_text.length) / 2
339
+
340
+ formatted = @pastel.dim(buttons_text)
341
+ print "\e[#{row};#{button_col}H#{formatted}"
342
+ end
343
+
344
+ # Clear the modal area
345
+ private def clear_modal(start_row, start_col)
346
+ @height.times do |i|
347
+ print "\e[#{start_row + i};#{start_col}H#{' ' * @width}"
348
+ end
349
+ end
350
+
351
+ # Draw menu choices
352
+ private def draw_menu_choices(start_row, start_col)
353
+ @choices.each_with_index do |choice, index|
354
+ row = start_row + index
355
+ draw_menu_choice(choice, index, row, start_col)
356
+ end
357
+ end
358
+
359
+ # Draw a single menu choice
360
+ private def draw_menu_choice(choice, index, row, col)
361
+ is_selected = (index == @selected_index)
362
+
363
+ # Calculate available width for text
364
+ text_width = @width - 6 # Account for borders, marker, and padding
365
+
366
+ # Prepare choice text
367
+ choice_text = choice[:name] || ""
368
+ if choice_text.length > text_width
369
+ choice_text = choice_text[0..text_width-4] + "..."
370
+ end
371
+
372
+ # Prepare marker and styling
373
+ if choice[:disabled]
374
+ # Disabled choice (like separator)
375
+ marker = " "
376
+ text = @pastel.dim(choice_text)
377
+ elsif is_selected
378
+ marker = @pastel.bright_cyan("→ ")
379
+ text = @pastel.bright_white(choice_text)
380
+ else
381
+ marker = " "
382
+ text = @pastel.white(choice_text)
383
+ end
384
+
385
+ # Draw line
386
+ print "\e[#{row};#{col}H"
387
+ print @pastel.cyan("│") + marker + text
388
+
389
+ # Pad to width
390
+ used_length = choice_text.length + 2 # marker is 2 chars
391
+ padding_needed = @width - used_length - 2 # -2 for borders
392
+ print " " * padding_needed + @pastel.cyan("│")
393
+ end
394
+
395
+ # Draw menu instructions
396
+ private def draw_menu_instructions(row, col)
397
+ instructions = "↑↓/jk: Navigate • Enter: Select • Esc/q: Cancel"
398
+ padding = (@width - instructions.length - 2) / 2
399
+ remaining = @width - padding - instructions.length - 2
400
+
401
+ print "\e[#{row};#{col}H"
402
+ border_line = @pastel.cyan("└" + "─" * padding)
403
+ instr_part = @pastel.dim(instructions)
404
+ border_rest = @pastel.cyan("─" * remaining + "┘")
405
+ print border_line + instr_part + border_rest
406
+ end
407
+
408
+ # Move menu selection up or down
409
+ private def move_menu_selection(direction)
410
+ loop do
411
+ @selected_index = (@selected_index + direction) % @choices.length
412
+ # Skip disabled choices
413
+ break unless @choices[@selected_index][:disabled]
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
419
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Octo
6
+ module UI2
7
+ module Components
8
+ # TodoArea displays active todos above the separator line
9
+ class TodoArea
10
+ attr_accessor :height
11
+ attr_reader :todos
12
+
13
+ MAX_DISPLAY_TASKS = 3 # Show current + next 2 tasks
14
+
15
+ def initialize
16
+ @todos = []
17
+ @pending_todos = []
18
+ @completed_count = 0
19
+ @total_count = 0
20
+ @pastel = Pastel.new
21
+ @width = TTY::Screen.width
22
+ @height = 0 # Dynamic height based on todos
23
+ @hidden = false
24
+ end
25
+
26
+ # Update todos list
27
+ # @param todos [Array<Hash>] Array of todo items
28
+ def update(todos)
29
+ @todos = todos || []
30
+ @pending_todos = @todos.select { |t| t[:status] == "pending" }
31
+ @completed_count = @todos.count { |t| t[:status] == "completed" }
32
+ @total_count = @todos.size
33
+
34
+ recalc_height
35
+ end
36
+
37
+ # Hide the area without discarding todos data; show again to restore.
38
+ def hide
39
+ return if @hidden
40
+ @hidden = true
41
+ @height = 0
42
+ end
43
+
44
+ def show
45
+ return unless @hidden
46
+ @hidden = false
47
+ recalc_height
48
+ end
49
+
50
+ private def recalc_height
51
+ if @hidden || @pending_todos.empty?
52
+ @height = 0
53
+ else
54
+ @height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
55
+ end
56
+ end
57
+
58
+ # Check if there are todos to display
59
+ def visible?
60
+ @height > 0
61
+ end
62
+
63
+ # Render todos area
64
+ # @param start_row [Integer] Screen row to start rendering
65
+ def render(start_row:)
66
+ return unless visible?
67
+
68
+ update_width
69
+
70
+ # Render each task on separate line
71
+ tasks_to_show = @pending_todos.take(MAX_DISPLAY_TASKS)
72
+
73
+ tasks_to_show.each_with_index do |task, index|
74
+ move_cursor(start_row + index, 0)
75
+
76
+ # Build the line content
77
+ line_content = if index == 0
78
+ # First line: Task [2/4]: #3 - Current task description
79
+ progress = "#{@completed_count}/#{@total_count}"
80
+ prefix = "Task [#{progress}]: "
81
+ task_text = "##{task[:id]} - #{task[:task]}"
82
+ available_width = @width - prefix.length - 2
83
+ truncated_task = truncate_text(task_text, available_width)
84
+
85
+ "#{@pastel.cyan(prefix)}#{truncated_task}"
86
+ else
87
+ # Subsequent lines: -> Next: #4 - Next task description
88
+ label = index == 1 ? "Next" : "After"
89
+ prefix = "-> #{label}: "
90
+ task_text = "##{task[:id]} - #{task[:task]}"
91
+ available_width = @width - prefix.length - 2
92
+ truncated_task = truncate_text(task_text, available_width)
93
+
94
+ "#{@pastel.dim(prefix)}#{@pastel.dim(truncated_task)}"
95
+ end
96
+
97
+ # Use carriage return and print content directly (overwrite existing content)
98
+ print "\r#{line_content}"
99
+ # Clear any remaining characters from previous render if line is shorter
100
+ clear_to_end_of_line
101
+ end
102
+
103
+ flush
104
+ end
105
+
106
+ # Clear the area
107
+ def clear
108
+ @todos = []
109
+ @pending_todos = []
110
+ @completed_count = 0
111
+ @total_count = 0
112
+ @height = 0
113
+ end
114
+
115
+
116
+ # Truncate text to fit width
117
+ def truncate_text(text, max_width)
118
+ return "" if text.nil?
119
+
120
+ if text.length > max_width
121
+ text[0...(max_width - 3)] + "..."
122
+ else
123
+ text
124
+ end
125
+ end
126
+
127
+ # Update width on resize
128
+ def update_width
129
+ @width = TTY::Screen.width
130
+ end
131
+
132
+ # Move cursor to position
133
+ def move_cursor(row, col)
134
+ print "\e[#{row + 1};#{col + 1}H"
135
+ end
136
+
137
+ # Clear from cursor to end of line
138
+ def clear_to_end_of_line
139
+ print "\e[0K"
140
+ end
141
+
142
+ # Flush output
143
+ def flush
144
+ $stdout.flush
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end