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,1625 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout_manager"
4
+ require_relative "view_renderer"
5
+ require_relative "progress_handle"
6
+ require_relative "components/input_area"
7
+ require_relative "components/todo_area"
8
+ require_relative "components/welcome_banner"
9
+ require_relative "components/inline_input"
10
+ require_relative "thinking_verbs"
11
+ require_relative "../ui_interface"
12
+
13
+ module Octo
14
+ module UI2
15
+ # UIController is the MVC controller layer that coordinates UI state and user interactions
16
+ class UIController
17
+ include Octo::UIInterface
18
+
19
+ attr_reader :layout, :renderer, :running, :inline_input, :input_area
20
+ attr_accessor :config
21
+
22
+ def initialize(config = {})
23
+ @renderer = ViewRenderer.new
24
+
25
+ # Set theme if specified
26
+ ThemeManager.set_theme(config[:theme]) if config[:theme]
27
+
28
+ # Store configuration
29
+ @config = {
30
+ working_dir: config[:working_dir],
31
+ mode: config[:mode],
32
+ model: config[:model],
33
+ theme: config[:theme]
34
+ }
35
+
36
+ # Initialize layout components
37
+ @input_area = Components::InputArea.new
38
+ @todo_area = Components::TodoArea.new
39
+ @welcome_banner = Components::WelcomeBanner.new
40
+ @inline_input = nil # Created when needed
41
+ @layout = LayoutManager.new(
42
+ input_area: @input_area,
43
+ todo_area: @todo_area
44
+ )
45
+
46
+ @running = false
47
+ @input_callback = nil
48
+ @interrupt_callback = nil
49
+ @time_machine_callback = nil
50
+ @tasks_count = 0
51
+ @session_id = nil
52
+ @last_diff_lines = nil
53
+
54
+ # ── Progress subsystem (v2: owned handles, stacked) ──────────────
55
+ # Every progress indicator is an owned ProgressHandle. UiController
56
+ # is the "owner" in the handle protocol: it keeps a stack of live
57
+ # handles, only the top of which is rendered. See ProgressHandle
58
+ # for the full protocol and stack semantics.
59
+ @progress_stack = []
60
+ @progress_mutex = Mutex.new
61
+ end
62
+
63
+ # Start the UI controller
64
+ def start
65
+ initialize_and_show_banner
66
+ start_input_loop
67
+ end
68
+
69
+ # Initialize screen and show banner (separate from input loop)
70
+ # @param recent_user_messages [Array<String>, nil] Recent user messages when loading session
71
+ def initialize_and_show_banner(recent_user_messages: nil)
72
+ @running = true
73
+
74
+ # Set session bar data before initializing screen
75
+ @input_area.update_sessionbar(
76
+ session_id: @session_id,
77
+ working_dir: @config[:working_dir],
78
+ mode: @config[:mode],
79
+ model: @config[:model],
80
+ tasks: @tasks_count
81
+ )
82
+
83
+ @layout.initialize_screen
84
+
85
+ # Display welcome banner or session history
86
+ if recent_user_messages && !recent_user_messages.empty?
87
+ display_session_history(recent_user_messages)
88
+ else
89
+ display_welcome_banner
90
+ end
91
+ end
92
+
93
+ # Start input loop (separate from initialization)
94
+ def start_input_loop
95
+ @running = true
96
+ input_loop
97
+ end
98
+
99
+ # Set skill loader for command suggestions in the input area
100
+ # @param skill_loader [Octo::SkillLoader] The skill loader instance
101
+ # @param agent_profile [Octo::AgentProfile, nil] Current agent profile for skill filtering
102
+ def set_skill_loader(skill_loader, agent_profile = nil)
103
+ @input_area.set_skill_loader(skill_loader, agent_profile)
104
+ end
105
+
106
+ # Update session bar with current stats
107
+ # @param tasks [Integer] Number of completed tasks (optional)
108
+ # @param status [String] Workspace status ('idle' or 'working') (optional)
109
+ # @param latency [Hash, nil] Latency metrics; accepted but not displayed in the TUI.
110
+ # @param session_id [String, nil] Full session id; rendered as first 8 chars (parity with WebUI).
111
+ def update_sessionbar(tasks: nil, status: nil, latency: nil, session_id: nil)
112
+ @tasks_count = tasks if tasks
113
+ @session_id = session_id if session_id
114
+ @input_area.update_sessionbar(
115
+ session_id: @session_id,
116
+ working_dir: @config[:working_dir],
117
+ mode: @config[:mode],
118
+ model: @config[:model],
119
+ tasks: @tasks_count,
120
+ status: status
121
+ )
122
+ @layout.render_input
123
+ end
124
+
125
+ # Toggle permission mode between confirm_safes and auto_approve
126
+ def toggle_mode
127
+ current_mode = @config[:mode]
128
+ new_mode = case current_mode.to_s
129
+ when /confirm_safes/
130
+ "auto_approve"
131
+ when /auto_approve/
132
+ "confirm_safes"
133
+ else
134
+ "auto_approve" # Default to auto_approve if unknown mode
135
+ end
136
+
137
+ @config[:mode] = new_mode
138
+
139
+ # Notify CLI to update agent_config
140
+ @mode_toggle_callback&.call(new_mode)
141
+
142
+ update_sessionbar
143
+ end
144
+
145
+ # Stop the UI controller
146
+ def stop
147
+ @running = false
148
+ @layout.cleanup_screen
149
+ end
150
+
151
+ # Clear the input area
152
+ def clear_input
153
+ @input_area.clear
154
+ end
155
+
156
+ # Set input tips message
157
+ # @param message [String] Tip message to display
158
+ # @param type [Symbol] Tip type (:info, :warning, etc.)
159
+ def set_input_tips(message, type: :info)
160
+ @input_area.set_tips(message, type: type)
161
+ end
162
+
163
+ # Set callback for user input
164
+ # @param block [Proc] Callback to execute with user input
165
+ def on_input(&block)
166
+ @input_callback = block
167
+ end
168
+
169
+ # Set callback for interrupt (Ctrl+C)
170
+ # @param block [Proc] Callback to execute on interrupt
171
+ def on_interrupt(&block)
172
+ @interrupt_callback = block
173
+ end
174
+
175
+ # Set callback for mode toggle (Shift+Tab)
176
+ # @param block [Proc] Callback to execute on mode toggle
177
+ def on_mode_toggle(&block)
178
+ @mode_toggle_callback = block
179
+ end
180
+
181
+ # Set callback for time machine (ESC key)
182
+ # @param block [Proc] Callback to execute on time machine
183
+ def on_time_machine(&block)
184
+ @time_machine_callback = block
185
+ end
186
+
187
+ # Set agent for command suggestions
188
+ # @param agent [Octo::Agent] The agent instance with skill management
189
+ # @param agent_profile [Octo::AgentProfile, nil] Current agent profile for skill filtering
190
+ def set_agent(agent, agent_profile = nil)
191
+ @input_area.set_agent(agent, agent_profile)
192
+ end
193
+
194
+ # Append output to the output area.
195
+ #
196
+ # If a progress indicator is currently active (somewhere in the
197
+ # buffer), rotate it to the tail after the append: business content
198
+ # ends up above, the spinner stays at the bottom. Without this,
199
+ # every subsequent ticker tick on a non-tail progress entry would
200
+ # trigger a full output repaint (visible flicker) and the visual
201
+ # order would have business messages appearing below the spinner.
202
+ def append_output(content)
203
+ @progress_mutex.synchronize do
204
+ top = @progress_stack.last
205
+ if top && top.entry_id
206
+ @layout.remove_entry(top.entry_id)
207
+ top.__detach_entry!
208
+ new_id = @layout.append_output(content)
209
+ progress_id = @layout.append_output(render_for(top))
210
+ top.__rebind_entry!(progress_id)
211
+ new_id
212
+ else
213
+ @layout.append_output(content)
214
+ end
215
+ end
216
+ end
217
+
218
+ # Internal append that bypasses the progress-rotation logic and the
219
+ # @progress_mutex. Used by register_progress / unregister_progress,
220
+ # which already hold the mutex and are themselves placing a fresh
221
+ # progress entry at the tail.
222
+ private def append_output_unlocked(content)
223
+ @layout.append_output(content)
224
+ end
225
+
226
+ # Log message to output area (use instead of puts)
227
+ # @param message [String] Message to log
228
+ # @param level [Symbol] Log level (:debug, :info, :warning, :error)
229
+ def log(message, level: :info)
230
+ theme = ThemeManager.current_theme
231
+
232
+ output = case level
233
+ when :debug
234
+ # Gray dimmed text for debug messages
235
+ theme.format_text(" [DEBUG] #{message}", :thinking)
236
+ when :info
237
+ # Info symbol with normal text
238
+ "#{theme.format_symbol(:info)} #{message}"
239
+ when :warning
240
+ # Warning rendering
241
+ @renderer.render_warning(message)
242
+ when :error
243
+ # Error rendering
244
+ @renderer.render_error(message)
245
+ else
246
+ # Default to info
247
+ "#{theme.format_symbol(:info)} #{message}"
248
+ end
249
+
250
+ append_output(output)
251
+ end
252
+
253
+ # Update an OutputBuffer entry's content by id.
254
+ # @param id [Integer, nil] Entry id (no-op if nil or already committed)
255
+ # @param content [String] New content for the entry
256
+ private def update_entry(id, content)
257
+ return unless id
258
+ @layout.replace_entry(id, content)
259
+ end
260
+
261
+ # Remove an OutputBuffer entry by id (no-op if nil or committed).
262
+ # @param id [Integer, nil]
263
+ private def remove_entry(id)
264
+ return unless id
265
+ @layout.remove_entry(id)
266
+ end
267
+
268
+ # Update todos display
269
+ # @param todos [Array<Hash>] Array of todo items
270
+ def update_todos(todos)
271
+ @layout.update_todos(todos)
272
+ end
273
+
274
+ # Display token usage statistics
275
+ # @param token_data [Hash] Token usage data containing:
276
+ # - delta_tokens: token delta from previous iteration
277
+ # - prompt_tokens: input tokens
278
+ # - completion_tokens: output tokens
279
+ # - total_tokens: total tokens
280
+ # - cache_write: cache write tokens
281
+ # - cache_read: cache read tokens
282
+ def show_token_usage(token_data)
283
+ theme = ThemeManager.current_theme
284
+ pastel = Pastel.new
285
+
286
+ token_info = []
287
+
288
+ # Delta tokens with color coding (green/yellow/red + dim)
289
+ delta_tokens = token_data[:delta_tokens]
290
+ delta_str = delta_tokens.negative? ? "#{delta_tokens}" : "+#{delta_tokens}"
291
+ color_style = if delta_tokens > 10000
292
+ :red
293
+ elsif delta_tokens > 5000
294
+ :yellow
295
+ else
296
+ :green
297
+ end
298
+ colored_delta = if delta_tokens.negative?
299
+ pastel.cyan(delta_str)
300
+ else
301
+ pastel.decorate(delta_str, color_style, :dim)
302
+ end
303
+ token_info << colored_delta
304
+
305
+ # Cache status indicator (using theme)
306
+ cache_write = token_data[:cache_write]
307
+ cache_read = token_data[:cache_read]
308
+ cache_used = cache_read > 0 || cache_write > 0
309
+ if cache_used
310
+ token_info << pastel.dim(theme.symbol(:cached))
311
+ end
312
+
313
+ # Input tokens (with cache breakdown if available)
314
+ prompt_tokens = token_data[:prompt_tokens]
315
+ if cache_write > 0 || cache_read > 0
316
+ input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
317
+ token_info << pastel.dim("Input: #{input_detail}")
318
+ else
319
+ token_info << pastel.dim("Input: #{prompt_tokens}")
320
+ end
321
+
322
+ # Output tokens
323
+ token_info << pastel.dim("Output: #{token_data[:completion_tokens]}")
324
+
325
+ # Total
326
+ token_info << pastel.dim("Total: #{token_data[:total_tokens]}")
327
+
328
+ # Display through output system (already all dimmed, just add prefix)
329
+ token_display = pastel.dim(" [Tokens] ") + token_info.join(pastel.dim(' | '))
330
+ append_output(token_display)
331
+ end
332
+
333
+ # Show tool call arguments
334
+ # @param formatted_args [String] Formatted arguments string
335
+ def show_tool_args(formatted_args)
336
+ theme = ThemeManager.current_theme
337
+ append_output("\n#{theme.format_text("Args: #{formatted_args}", :thinking)}")
338
+ end
339
+
340
+ # Show file operation preview (Write tool)
341
+ # @param path [String] File path
342
+ # @param is_new_file [Boolean] Whether this is a new file
343
+ def show_file_write_preview(path, is_new_file:)
344
+ theme = ThemeManager.current_theme
345
+ file_label = theme.format_symbol(:file)
346
+ status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
347
+ append_output("\n#{file_label} #{path || '(unknown)'}")
348
+ append_output(status)
349
+ end
350
+
351
+ # Show file operation preview (Edit tool)
352
+ # @param path [String] File path
353
+ def show_file_edit_preview(path)
354
+ theme = ThemeManager.current_theme
355
+ file_label = theme.format_symbol(:file)
356
+ append_output("\n#{file_label} #{path || '(unknown)'}")
357
+ end
358
+
359
+ # Show file operation error
360
+ # @param error_message [String] Error message
361
+ def show_file_error(error_message)
362
+ theme = ThemeManager.current_theme
363
+ append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
364
+ end
365
+
366
+ # Show shell command preview
367
+ # @param command [String] Shell command
368
+ def show_shell_preview(command)
369
+ theme = ThemeManager.current_theme
370
+ cmd_label = theme.format_symbol(:command)
371
+ append_output("\n#{cmd_label} #{command}")
372
+ end
373
+
374
+ # === Semantic UI Methods (for Agent to call directly) ===
375
+
376
+ # Show assistant message
377
+ # @param content [String] Message content
378
+ def show_assistant_message(content, files:)
379
+ # Filter out thinking tags from models like MiniMax M2.1 that use <think>...</think>
380
+ filtered_content = filter_thinking_tags(content)
381
+ return if filtered_content.nil? || filtered_content.strip.empty?
382
+
383
+ output = @renderer.render_assistant_message(filtered_content)
384
+ append_output(output)
385
+ end
386
+
387
+ # Filter out thinking tags from content
388
+ # Some models (e.g., MiniMax M2.1) wrap their reasoning in <think>...</think> tags
389
+ # @param content [String] Raw content from model
390
+ # @return [String] Content with thinking tags removed
391
+ def filter_thinking_tags(content)
392
+ return content if content.nil?
393
+
394
+ # Remove <think>...</think> blocks (multiline, case-insensitive)
395
+ # Also handles variations like <thinking>...</thinking>
396
+ filtered = content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, '')
397
+
398
+ # Clean up multiple empty lines left behind (max 2 consecutive newlines)
399
+ filtered.gsub!(/\n{3,}/, "\n\n")
400
+
401
+ # Remove leading and trailing whitespace
402
+ filtered.strip
403
+ end
404
+
405
+ # Show tool call
406
+ # @param name [String] Tool name
407
+ # @param args [String, Hash] Tool arguments (JSON string or Hash)
408
+ def show_tool_call(name, args)
409
+ # Reset stdout buffer on each new tool call so previous command output
410
+ # doesn't bleed into the next one, and so the buffer is ready before
411
+ # on_output starts firing (which can happen before show_progress is called).
412
+ @stdout_lines = nil
413
+
414
+ # Special handling for request_user_feedback: render as a readable interactive card
415
+ # with the full question and options, rather than the truncated format_call summary.
416
+ if name.to_s == "request_user_feedback"
417
+ args_data = args.is_a?(String) ? (JSON.parse(args, symbolize_names: true) rescue {}) : args
418
+ args_data = args_data.transform_keys(&:to_sym) if args_data.is_a?(Hash)
419
+
420
+ question = args_data[:question].to_s.strip
421
+ context = args_data[:context].to_s.strip
422
+ options = Array(args_data[:options])
423
+
424
+ theme = ThemeManager.current_theme
425
+ parts = []
426
+
427
+ parts << context unless context.empty?
428
+ parts << question unless question.empty?
429
+
430
+ if options.any?
431
+ parts << ""
432
+ options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
433
+ end
434
+
435
+ card_text = parts.join("\n")
436
+ output = @renderer.render_system_message(card_text, prefix_newline: true)
437
+ append_output(output)
438
+ return
439
+ end
440
+
441
+ formatted_call = format_tool_call(name, args)
442
+ output = @renderer.render_tool_call(tool_name: name, formatted_call: formatted_call)
443
+ append_output(output)
444
+ end
445
+
446
+ # Show tool result
447
+ # @param result [String] Formatted tool result
448
+ def show_tool_result(result)
449
+ output = @renderer.render_tool_result(result: result)
450
+ append_output(output)
451
+ end
452
+
453
+ # Show tool error
454
+ # @param error [String, Exception] Error message or exception
455
+ def show_tool_error(error)
456
+ error_msg = error.is_a?(Exception) ? error.message : error.to_s
457
+ output = @renderer.render_tool_error(error: error_msg)
458
+ append_output(output)
459
+ end
460
+
461
+ # Receive a chunk of shell stdout from the on_output callback.
462
+ # Lines are buffered into @stdout_lines so that Ctrl+O can open a
463
+ # fullscreen live view, matching the original output_buffer interaction.
464
+ # @param lines [Array<String>] One or more stdout chunks
465
+ def show_tool_stdout(lines)
466
+ return if lines.nil? || lines.empty?
467
+ @stdout_lines ||= []
468
+ @stdout_lines.concat(lines.map(&:chomp))
469
+ end
470
+
471
+ # Show completion status (only for tasks with more than 5 iterations)
472
+ # @param iterations [Integer] Number of iterations
473
+ # @param duration [Float] Duration in seconds
474
+ # @param cache_stats [Hash] Cache statistics
475
+ # @param awaiting_user_feedback [Boolean] Whether agent is waiting for user feedback
476
+ def show_complete(iterations:, duration: nil, cache_stats: nil, awaiting_user_feedback: false)
477
+ # Update status back to 'idle' when task is complete
478
+ update_sessionbar(status: 'idle')
479
+
480
+ # Clear user tip when agent stops working
481
+ @input_area.clear_user_tip
482
+ # Hide todo area while idle (data preserved, restored on next work)
483
+ @layout.hide_todos
484
+ @layout.render_input
485
+
486
+ # Don't show completion message if awaiting user feedback
487
+ return if awaiting_user_feedback
488
+
489
+ # Only show completion message for complex tasks (>5 iterations)
490
+ return if iterations <= 5
491
+
492
+ cache_tokens = cache_stats&.dig(:cache_read_input_tokens)
493
+ cache_requests = cache_stats&.dig(:total_requests)
494
+ cache_hits = cache_stats&.dig(:cache_hit_requests)
495
+
496
+ output = @renderer.render_task_complete(
497
+ iterations: iterations,
498
+ duration: duration,
499
+ cache_tokens: cache_tokens,
500
+ cache_requests: cache_requests,
501
+ cache_hits: cache_hits
502
+ )
503
+ append_output(output)
504
+ end
505
+
506
+ # Show progress indicator with dynamic elapsed time
507
+ # @param message [String] Progress message (optional, will use random thinking verb if nil)
508
+ # ---------------------------------------------------------------------
509
+ # Progress indicator API (v2)
510
+ #
511
+ # The preferred public API is +start_progress+ / +with_progress+, which
512
+ # returns a ProgressHandle the caller owns. Use +with_progress+ whenever
513
+ # possible — it uses +ensure+ to guarantee cleanup even on exceptions
514
+ # (e.g. AgentInterrupted during idle compression).
515
+ #
516
+ # The legacy +show_progress(message, phase:, ...)+ method is kept as a
517
+ # thin shim for existing call sites that haven't been migrated yet.
518
+ # ---------------------------------------------------------------------
519
+
520
+ # Start a new progress indicator and return its owned handle.
521
+ #
522
+ # @param message [String] Initial progress message.
523
+ # @param style [Symbol] :primary (foreground, yellow, bumps sessionbar)
524
+ # or :quiet (background, gray, no sessionbar change).
525
+ # @param quiet_on_fast_finish [Boolean] See ProgressHandle — when true,
526
+ # a finish that elapses under FAST_FINISH_THRESHOLD_SECONDS removes
527
+ # the entry instead of leaving a permanent final frame. Preferred
528
+ # for tool-execution wrappers.
529
+ # @return [Octo::UI2::ProgressHandle]
530
+ def start_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
531
+ display = (message.nil? || message.to_s.strip.empty?) ? Octo::THINKING_VERBS.sample : message.to_s
532
+ ProgressHandle.new(
533
+ owner: self,
534
+ message: display,
535
+ style: style,
536
+ quiet_on_fast_finish: quiet_on_fast_finish
537
+ ).start
538
+ end
539
+
540
+ # Run the given block with a progress indicator active. The handle is
541
+ # always finished in an +ensure+ block, so exceptions (including
542
+ # Thread#raise) cannot leave the ticker or entry orphaned.
543
+ #
544
+ # @yieldparam handle [Octo::UI2::ProgressHandle]
545
+ def with_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
546
+ handle = start_progress(
547
+ message: message,
548
+ style: style,
549
+ quiet_on_fast_finish: quiet_on_fast_finish
550
+ )
551
+ begin
552
+ yield handle
553
+ ensure
554
+ handle.finish
555
+ end
556
+ end
557
+
558
+ # Returns true if any progress indicator is currently active.
559
+ def progress_active?
560
+ @progress_mutex.synchronize { !@progress_stack.empty? }
561
+ end
562
+
563
+ # Finish every active progress handle, top to bottom. Used by the
564
+ # interrupt path (Ctrl+C) so a single keypress guarantees the UI is
565
+ # quiescent regardless of how many nested/background progresses are
566
+ # running.
567
+ def interrupt_all_progress
568
+ # Snapshot outside the handle's finish() (which also grabs the
569
+ # mutex via unregister_progress) to avoid re-entrant lock issues.
570
+ handles = @progress_mutex.synchronize { @progress_stack.dup }
571
+ # Finish from top (newest) to bottom (oldest) so each top is the
572
+ # one currently rendering when it finishes.
573
+ handles.reverse_each(&:finish)
574
+ # Also drop legacy-shim handle registry so a subsequent
575
+ # show_progress(phase: "done") from unmigrated callers is a no-op.
576
+ @legacy_progress_handles&.clear
577
+ end
578
+
579
+ # ----- Owner protocol for ProgressHandle ----------------------------
580
+ #
581
+ # These three methods implement the contract described in
582
+ # ProgressHandle's class documentation. They are part of the public
583
+ # API only for ProgressHandle — external callers should use
584
+ # +start_progress+ / +with_progress+ instead.
585
+
586
+ # Called by ProgressHandle#start.
587
+ def register_progress(handle)
588
+ @progress_mutex.synchronize do
589
+ prev_top = @progress_stack.last
590
+ if prev_top
591
+ # Plan B: the lower handle loses its OutputBuffer entry until
592
+ # the new top finishes. We remove its on-screen line now and
593
+ # tell it to forget its id; we'll allocate a new id on restore.
594
+ remove_entry(prev_top.entry_id)
595
+ prev_top.__detach_entry!
596
+ end
597
+
598
+ @progress_stack.push(handle)
599
+ entry_id = append_output_unlocked(render_for(handle))
600
+ recompute_sessionbar_status
601
+ entry_id
602
+ end
603
+ end
604
+
605
+ # Called by ProgressHandle#finish.
606
+ def unregister_progress(handle, final_frame:)
607
+ @progress_mutex.synchronize do
608
+ # If this handle still holds its entry (it's currently top), we
609
+ # render one last frame there and release the id. If it was
610
+ # previously detached (someone above is still active), its entry
611
+ # is already gone and the final_frame is simply dropped.
612
+ if handle.entry_id
613
+ if final_frame && !final_frame.to_s.strip.empty?
614
+ update_entry(handle.entry_id, @renderer.render_progress(final_frame))
615
+ else
616
+ remove_entry(handle.entry_id)
617
+ end
618
+ end
619
+
620
+ @progress_stack.delete(handle)
621
+
622
+ # Restore the new top, if any: allocate a fresh entry and let it
623
+ # resume rendering from where it left off.
624
+ if (restored = @progress_stack.last)
625
+ new_id = append_output_unlocked(render_for(restored))
626
+ restored.__reattach_entry!(new_id)
627
+ end
628
+
629
+ # Recompute sessionbar status from whatever remains on the stack.
630
+ # This handles: (a) empty stack → idle, (b) mixed stack (e.g. a
631
+ # long-running quiet tool still active underneath) → working.
632
+ recompute_sessionbar_status
633
+ end
634
+ end
635
+
636
+ # Called by ProgressHandle's ticker and +update+. Writes +frame+ into
637
+ # the handle's entry iff it is currently top-of-stack. Non-top
638
+ # handles silently do nothing (their entry was detached in
639
+ # +register_progress+).
640
+ def render_frame(handle, frame)
641
+ @progress_mutex.synchronize do
642
+ return unless @progress_stack.last == handle
643
+ return unless handle.entry_id
644
+
645
+ has_output = @stdout_lines && !@stdout_lines.empty?
646
+ suffix = has_output ?
647
+ " (Ctrl+C to interrupt · Ctrl+O to view output)" :
648
+ " (Ctrl+C to interrupt)"
649
+ decorated = "#{frame}#{suffix}"
650
+
651
+ painted = handle.style == :primary ?
652
+ @renderer.render_working(decorated) :
653
+ @renderer.render_progress(decorated)
654
+ update_entry(handle.entry_id, painted)
655
+
656
+ # Re-evaluate sessionbar: a quiet handle that crosses the fast-finish
657
+ # threshold should upgrade the status bar to "working" so long-running
658
+ # tools (terminal running a build, web_fetch) visibly reflect activity.
659
+ recompute_sessionbar_status
660
+ end
661
+ end
662
+
663
+ # Render the very first frame of +handle+ (used when registering or
664
+ # restoring). Mirrors +render_frame+'s formatting minus the implicit
665
+ # elapsed-time tick (handle hasn't ticked yet).
666
+ private def render_for(handle)
667
+ frame = handle.current_frame
668
+ has_output = @stdout_lines && !@stdout_lines.empty?
669
+ suffix = has_output ?
670
+ " (Ctrl+C to interrupt · Ctrl+O to view output)" :
671
+ " (Ctrl+C to interrupt)"
672
+ decorated = "#{frame}#{suffix}"
673
+ handle.style == :primary ?
674
+ @renderer.render_working(decorated) :
675
+ @renderer.render_progress(decorated)
676
+ end
677
+
678
+ # Derive the sessionbar workspace status from the live progress stack.
679
+ #
680
+ # Rules:
681
+ # - Any :primary handle alive → "working" (fast path for LLM thinking)
682
+ # - Any :quiet handle that has been alive longer than
683
+ # FAST_FINISH_THRESHOLD_SECONDS → "working" (so long tools like
684
+ # `terminal` running a build or test suite correctly flip the bar
685
+ # to working instead of staying on "idle" for minutes)
686
+ # - Otherwise → "idle"
687
+ #
688
+ # Must be called with @progress_mutex held. Emits update_sessionbar
689
+ # only when the computed status differs from the last one we wrote,
690
+ # avoiding pointless re-renders on every tick.
691
+ private def recompute_sessionbar_status
692
+ new_status = compute_sessionbar_status
693
+ return if @last_sessionbar_status == new_status
694
+ @last_sessionbar_status = new_status
695
+ update_sessionbar(status: new_status)
696
+ end
697
+
698
+ private def compute_sessionbar_status
699
+ return 'idle' if @progress_stack.empty?
700
+
701
+ threshold = ProgressHandle::FAST_FINISH_THRESHOLD_SECONDS
702
+ now = Time.now
703
+ @progress_stack.each do |h|
704
+ return 'working' if h.style == :primary
705
+ # Quiet handles only "count" once they've been alive long enough
706
+ # that a user would naturally expect a busy indicator.
707
+ start = h.start_time
708
+ return 'working' if start && (now - start) >= threshold
709
+ end
710
+ 'idle'
711
+ end
712
+
713
+ # ---------------------------------------------------------------------
714
+ # Legacy shim: show_progress(message, phase:, progress_type:, ...)
715
+ #
716
+ # This method preserves the pre-refactor API so existing call sites
717
+ # (Agent#run, Agent#think, LlmCaller retry, trigger_idle_compression,
718
+ # MemoryUpdater) keep working until they're migrated to the owned-
719
+ # handle API.
720
+ #
721
+ # Each progress_type owns its own handle slot so two concurrent
722
+ # background flows (e.g. idle compression + thinking) can coexist
723
+ # without stomping on each other — exactly the race that caused the
724
+ # yellow/gray flicker bug.
725
+ # ---------------------------------------------------------------------
726
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
727
+ _ = prefix_newline # ignored in v2; layout is not the caller's concern
728
+
729
+ type = progress_type.to_s
730
+ style = %w[retrying idle_compress].include?(type) ? :quiet : :primary
731
+
732
+ @legacy_progress_handles ||= {}
733
+
734
+ if phase.to_s == "done"
735
+ handle = @legacy_progress_handles[type]
736
+ handle&.finish(final_message: message)
737
+ @legacy_progress_handles.delete(type)
738
+ return
739
+ end
740
+
741
+ # "active" phase — start a new handle or update the existing one.
742
+ existing = @legacy_progress_handles[type]
743
+ if existing&.running?
744
+ # Bare re-entry: no new info → just leave the existing spinner alone.
745
+ # This preserves the long-standing "Agent#run and Agent#think both
746
+ # call show_progress for fast feedback" idiom.
747
+ return if message.nil? && metadata.empty?
748
+
749
+ attempt = metadata[:attempt]
750
+ total = metadata[:total]
751
+ suffix = (attempt && total) ? " (#{attempt}/#{total})" : ""
752
+ existing.update(message: "#{message || existing.message}#{suffix}", metadata: metadata)
753
+ return
754
+ end
755
+
756
+ attempt = metadata[:attempt]
757
+ total = metadata[:total]
758
+ suffix = (attempt && total) ? " (#{attempt}/#{total})" : ""
759
+ display = ((message && !message.to_s.strip.empty?) ? message.to_s : Octo::THINKING_VERBS.sample) + suffix
760
+
761
+ @legacy_progress_handles[type] = start_progress(message: display, style: style)
762
+ end
763
+
764
+ # Stream-only update for the live thinking progress. Unlike
765
+ # +show_progress(progress_type: "thinking", phase: "active")+, this
766
+ # NEVER creates a new handle — if no thinking handle is currently
767
+ # alive (e.g. we're inside an idle-compression call_llm where only
768
+ # the quiet "Compressing..." handle is on the stack), the streamed
769
+ # token counts are silently dropped instead of spawning a primary
770
+ # spinner that would push the compression progress off-screen.
771
+ def stream_thinking_progress(input_tokens:, output_tokens:)
772
+ @legacy_progress_handles ||= {}
773
+ existing = @legacy_progress_handles["thinking"]
774
+ return unless existing&.running?
775
+ existing.update(metadata: { input_tokens: input_tokens, output_tokens: output_tokens })
776
+ end
777
+
778
+ # ---------------------------------------------------------------------
779
+ # (Legacy dead-code removed: the old imperative show_progress body
780
+ # used to live here and is now superseded by the shim + owner
781
+ # protocol above. Keeping this note so a future grep for
782
+ # "@progress_id" / "@progress_thread" finds the migration explanation.)
783
+ # ---------------------------------------------------------------------
784
+
785
+ # Stop the fullscreen refresh thread gracefully via flag + join.
786
+ def stop_fullscreen_refresh_thread
787
+ @fullscreen_refresh_stop = true
788
+ if @fullscreen_refresh_thread&.alive?
789
+ joined = @fullscreen_refresh_thread.join(1.0)
790
+ @fullscreen_refresh_thread.kill unless joined
791
+ end
792
+ @fullscreen_refresh_thread = nil
793
+ @fullscreen_refresh_stop = false
794
+ end
795
+
796
+ # Show info message
797
+ # @param message [String] Info message
798
+ # @param prefix_newline [Boolean] Whether to add newline before message (default: true)
799
+ def show_info(message, prefix_newline: true)
800
+ output = @renderer.render_system_message(message, prefix_newline: prefix_newline)
801
+ append_output(output)
802
+ end
803
+
804
+ # Show warning message
805
+ # @param message [String] Warning message
806
+ def show_warning(message)
807
+ output = @renderer.render_warning(message)
808
+ append_output(output)
809
+ end
810
+
811
+ # Show error message
812
+ # @param message [String] Error message
813
+ def show_error(message)
814
+ output = @renderer.render_error(message)
815
+ append_output(output)
816
+ end
817
+
818
+ # Show success message
819
+ # @param message [String] Success message
820
+ def show_success(message)
821
+ output = @renderer.render_success(message)
822
+ append_output(output)
823
+ end
824
+
825
+ # Set workspace status to idle (called when agent stops working)
826
+ def set_idle_status
827
+ # Safety net: close any legacy progress slots that were opened via
828
+ # show_progress(progress_type: X, phase: "active") but never paired
829
+ # with a corresponding phase: "done" call. Historically the
830
+ # "retrying" slot in LlmCaller was leaked on every successful
831
+ # recovery, leaving the user with a stale "Network failed ... (NNN s)"
832
+ # line ticking forever. LlmCaller now closes its own slot (see the
833
+ # ensure in call_llm), but we mirror that defense here so any
834
+ # future code path that forgets to close a slot still gets cleaned
835
+ # up at the well-defined idle boundary.
836
+ close_leaked_legacy_progress_handles
837
+
838
+ update_sessionbar(status: 'idle')
839
+ @last_sessionbar_status = 'idle'
840
+ # Clear user tip when agent stops working
841
+ @input_area.clear_user_tip
842
+ # Hide todo area while idle (data preserved, restored on next work)
843
+ @layout.hide_todos
844
+ @layout.render_input
845
+ end
846
+
847
+ # Finish every ProgressHandle still registered in the legacy
848
+ # (show_progress) handle map. Called from set_idle_status as a
849
+ # defense-in-depth against unpaired active/done calls.
850
+ private def close_leaked_legacy_progress_handles
851
+ return unless @legacy_progress_handles
852
+
853
+ leaked = @legacy_progress_handles.reject { |_type, h| h.nil? || !h.running? }
854
+ return if leaked.empty?
855
+
856
+ # Finish top-down so each handle is the one currently rendering
857
+ # when it closes (matches the invariant in interrupt_all_progress).
858
+ leaked.values.reverse_each(&:finish)
859
+
860
+ @legacy_progress_handles.clear
861
+ end
862
+
863
+ # Set workspace status to working (called when agent starts working)
864
+ def set_working_status
865
+ update_sessionbar(status: 'working')
866
+ # Restore todo area if it was hidden during idle
867
+ @layout.show_todos
868
+ # Show a random user tip with 40% probability when agent starts working
869
+ @input_area.show_user_tip(probability: 0.4)
870
+ @layout.render_input
871
+ end
872
+
873
+ # Show help text
874
+ def show_help
875
+ theme = ThemeManager.current_theme
876
+
877
+ # Separator line
878
+ separator = theme.format_text("─" * 60, :info)
879
+
880
+ lines = [
881
+ separator,
882
+ "",
883
+ theme.format_text("Commands:", :info),
884
+ " #{theme.format_text("/clear", :success)} - Clear output and restart session",
885
+ " #{theme.format_text("/exit", :success)} - Exit application",
886
+ "",
887
+ theme.format_text("Input:", :info),
888
+ " #{theme.format_text("Shift+Enter", :success)} - New line",
889
+ " #{theme.format_text("Up/Down", :success)} - History navigation",
890
+ " #{theme.format_text("Ctrl+V", :success)} - Paste image (Ctrl+D to delete, max 3)",
891
+ " #{theme.format_text("Ctrl+C", :success)} - Clear input (press 2x to exit)",
892
+ "",
893
+ theme.format_text("Other:", :info),
894
+ " Supports Emacs-style shortcuts (Ctrl+A, Ctrl+E, etc.)",
895
+ "",
896
+ separator
897
+ ]
898
+
899
+ lines.each { |line| append_output(line) }
900
+ end
901
+
902
+ # Request confirmation from user (blocking)
903
+ # @param message [String] Confirmation prompt
904
+ # @param default [Boolean] Default value if user presses Enter
905
+ # @return [Boolean, String, nil] true/false for yes/no, String for feedback, nil for cancelled
906
+ def request_confirmation(message, default: true)
907
+ # Show question in output with theme styling
908
+ theme = ThemeManager.current_theme
909
+ question_symbol = theme.format_symbol(:info)
910
+ append_output("#{question_symbol} #{message}")
911
+
912
+ # Pause InputArea
913
+ @input_area.pause
914
+ @layout.recalculate_layout
915
+
916
+ # Create InlineInput with styled prompt
917
+ inline_input = Components::InlineInput.new(
918
+ prompt: "Press Enter/y to approve(Shift+Tab for all), 'n' to reject, or type feedback: ",
919
+ default: nil
920
+ )
921
+ @inline_input = inline_input
922
+
923
+ # Add inline input line to output (use layout to track position)
924
+ inline_id = @layout.append_output(inline_input.render)
925
+ @layout.position_inline_input_cursor(inline_input)
926
+
927
+ result_text = nil
928
+ begin
929
+ # Collect input (blocks until user presses Enter).
930
+ # May raise AgentInterrupted if main thread Thread#raises the
931
+ # worker mid-pop — we MUST still restore input state in ensure.
932
+ result_text = inline_input.collect
933
+ ensure
934
+ # Clean up - remove the inline input lines (handle wrapped lines).
935
+ # Use the tracked id so removal is safe even when more output
936
+ # was appended in between.
937
+ @layout.remove_entry(inline_id) if inline_id
938
+
939
+ # Deactivate InlineInput and restore the main InputArea. This
940
+ # MUST run even on exception so the user can type after
941
+ # interrupting a confirmation prompt.
942
+ @inline_input = nil
943
+ @input_area.resume
944
+ @layout.recalculate_layout
945
+ @layout.render_all
946
+ end
947
+
948
+ # Append the final response to output (only on normal return)
949
+ if result_text.nil?
950
+ append_output(theme.format_text(" [Cancelled]", :error))
951
+ else
952
+ display_text = result_text.empty? ? (default ? "y" : "n") : result_text
953
+ append_output(theme.format_text(" #{display_text}", :success))
954
+ end
955
+
956
+ # Parse result
957
+ return nil if result_text.nil? # Cancelled
958
+
959
+ response = result_text.strip.downcase
960
+ case response
961
+ when "y", "yes" then true
962
+ when "n", "no" then false
963
+ when "" then default
964
+ else
965
+ result_text # Return feedback text
966
+ end
967
+ end
968
+
969
+ # Show diff between old and new content
970
+ # @param old_content [String] Old content
971
+ # @param new_content [String] New content
972
+ # @param max_lines [Integer] Maximum lines to show
973
+ def show_diff(old_content, new_content, max_lines: 50)
974
+ require 'diffy'
975
+
976
+ diff = Diffy::Diff.new(old_content, new_content, context: 3)
977
+ diff_lines = diff.to_s(:color).lines
978
+
979
+ # Store for fullscreen toggle
980
+ @last_diff_lines = diff_lines
981
+
982
+ # Show diff without line numbers
983
+ diff_lines.take(max_lines).each do |line|
984
+ append_output(line.chomp)
985
+ end
986
+
987
+ if diff_lines.size > max_lines
988
+ append_output("\n... (#{diff_lines.size - max_lines} more lines hidden. Press Ctrl+O to open full diff in pager)")
989
+ end
990
+ rescue LoadError
991
+ # Fallback if diffy is not available
992
+ append_output(" Old size: #{old_content.bytesize} bytes")
993
+ append_output(" New size: #{new_content.bytesize} bytes")
994
+ @last_diff_lines = nil
995
+ end
996
+
997
+ # Show fullscreen diff view (only if not already expanded)
998
+ private def redisplay_diff
999
+ return unless @last_diff_lines
1000
+ return if @layout.fullscreen_mode?
1001
+
1002
+ # Use `less -R` as pager: it handles its own alternate screen + scrolling,
1003
+ # and restores the terminal perfectly on exit — no DIY scrolling needed.
1004
+ content = @last_diff_lines.join
1005
+
1006
+ # Write diff to a temp file so less can read it
1007
+ require "tempfile"
1008
+ tmpfile = Tempfile.new(["octo_diff", ".txt"])
1009
+ tmpfile.write(content)
1010
+ tmpfile.flush
1011
+
1012
+ # Suspend raw mode so less can take full control of the terminal
1013
+ @layout.screen.disable_raw_mode
1014
+
1015
+ # --mouse : enable mouse wheel scrolling inside less
1016
+ # --wheel-lines : scroll 3 lines per wheel click (comfortable default)
1017
+ # -R : pass through ANSI colour codes
1018
+ # Unset LESSOPEN/LESSCLOSE so less doesn't try to pre-process the file
1019
+ system(
1020
+ { "LESSOPEN" => nil, "LESSCLOSE" => nil },
1021
+ "less", "--mouse", "--wheel-lines=3", "-R", tmpfile.path
1022
+ )
1023
+
1024
+ # Restore raw mode and repaint the main screen
1025
+ @layout.screen.enable_raw_mode
1026
+ @layout.rerender_all
1027
+ ensure
1028
+ tmpfile&.close
1029
+ tmpfile&.unlink
1030
+ end
1031
+
1032
+ # Show fullscreen command output view
1033
+ def show_command_output
1034
+ return unless @stdout_lines && !@stdout_lines.empty?
1035
+ return if @layout.fullscreen_mode?
1036
+
1037
+ lines = build_command_output_lines
1038
+ @layout.enter_fullscreen(lines, hint: "Press Ctrl+O to return · Output updates in real-time")
1039
+
1040
+ # Start background thread to refresh fullscreen content in real-time.
1041
+ # Use a dedicated stop flag so we can join() the thread cleanly and
1042
+ # avoid Thread#kill interrupting the thread while it holds @render_mutex.
1043
+ @fullscreen_refresh_stop = false
1044
+ @fullscreen_refresh_thread = Thread.new do
1045
+ until @fullscreen_refresh_stop || !@layout.fullscreen_mode?
1046
+ sleep 0.3
1047
+ next if @fullscreen_refresh_stop || !@layout.fullscreen_mode?
1048
+
1049
+ @layout.refresh_fullscreen(build_command_output_lines)
1050
+ end
1051
+ rescue StandardError
1052
+ # Silently handle thread errors
1053
+ end
1054
+ end
1055
+
1056
+
1057
+ # Build command output lines snapshot from @stdout_lines
1058
+ # @return [Array<String>] Lines to display in fullscreen
1059
+ private def build_command_output_lines
1060
+ lines = @stdout_lines&.dup || []
1061
+ lines.empty? ? ["(No output yet)"] : lines
1062
+ end
1063
+
1064
+ # Format tool call for display
1065
+ # @param name [String] Tool name
1066
+ # @param args [String, Hash] Tool arguments
1067
+ # @return [String] Formatted call string
1068
+ def format_tool_call(name, args)
1069
+ args_hash = args.is_a?(String) ? JSON.parse(args, symbolize_names: true) : args
1070
+
1071
+ # Try to get tool instance for custom formatting
1072
+ tool = get_tool_instance(name)
1073
+ if tool
1074
+ begin
1075
+ return tool.format_call(args_hash)
1076
+ rescue StandardError
1077
+ # Fallback
1078
+ end
1079
+ end
1080
+
1081
+ # Simple fallback
1082
+ "#{name}(...)"
1083
+ rescue JSON::ParserError
1084
+ "#{name}(...)"
1085
+ end
1086
+
1087
+ # Get tool instance by name
1088
+ # @param tool_name [String] Tool name
1089
+ # @return [Object, nil] Tool instance or nil
1090
+ def get_tool_instance(tool_name)
1091
+ # Convert tool_name to class name (e.g., "file_reader" -> "FileReader")
1092
+ class_name = tool_name.split('_').map(&:capitalize).join
1093
+
1094
+ # Try to find the class in Octo::Tools namespace
1095
+ if Octo::Tools.const_defined?(class_name)
1096
+ tool_class = Octo::Tools.const_get(class_name)
1097
+ tool_class.new
1098
+ else
1099
+ nil
1100
+ end
1101
+ rescue NameError
1102
+ nil
1103
+ end
1104
+
1105
+ # Display welcome banner with logo and agent info
1106
+ def display_welcome_banner
1107
+ content = @welcome_banner.render_full(
1108
+ working_dir: @config[:working_dir],
1109
+ mode: @config[:mode],
1110
+ width: @layout.screen.width
1111
+ )
1112
+ append_output(content)
1113
+
1114
+ # Check if API key is configured (show warning AFTER banner)
1115
+ check_api_key_configuration
1116
+ end
1117
+
1118
+ # Check if API key is configured and show warning if missing
1119
+ private def check_api_key_configuration
1120
+ config = Octo::AgentConfig.load
1121
+
1122
+ if !config.models_configured?
1123
+ show_warning("No models configured! Please run /config to set up your models and API keys.")
1124
+ elsif config.api_key.nil? || config.api_key.empty?
1125
+ show_warning("API key is not configured! Please run /config to set up your API key.")
1126
+ end
1127
+ end
1128
+
1129
+ # Display recent user messages when loading session
1130
+ # @param user_messages [Array<String>] Array of recent user message texts
1131
+ def display_session_history(user_messages)
1132
+ theme = ThemeManager.current_theme
1133
+
1134
+ # Show logo banner only
1135
+ append_output(@welcome_banner.render_logo(width: @layout.screen.width))
1136
+
1137
+ # Show simple header
1138
+ append_output(theme.format_text("Recent conversation:", :info))
1139
+
1140
+ # Display each user message with numbering
1141
+ user_messages.each_with_index do |msg, index|
1142
+ # Truncate long messages
1143
+ display_msg = if msg.length > 140
1144
+ "#{msg[0..137]}..."
1145
+ else
1146
+ msg
1147
+ end
1148
+
1149
+ # Show with number and indentation
1150
+ append_output(" #{index + 1}. #{display_msg}")
1151
+ end
1152
+
1153
+ # Bottom spacing and continuation prompt
1154
+ append_output("")
1155
+ append_output(theme.format_text("Session restored. Feel free to continue with your next task.", :success))
1156
+ end
1157
+
1158
+ # Main input loop
1159
+ def input_loop
1160
+ @layout.screen.enable_raw_mode
1161
+
1162
+ while @running
1163
+ # Process any pending resize events
1164
+ @layout.process_pending_resize
1165
+
1166
+ key = @layout.screen.read_key(timeout: 0.1)
1167
+ next unless key
1168
+
1169
+ handle_key(key)
1170
+ end
1171
+ rescue Octo::AgentInterrupted
1172
+ # Signal.trap("INT") raised AgentInterrupted on the main thread.
1173
+ # Route through the normal Ctrl+C path so on_interrupt callback fires
1174
+ # (interrupt task thread if running, or exit if idle) instead of quitting blindly.
1175
+ handle_key(:ctrl_c)
1176
+ rescue => e
1177
+ stop
1178
+ raise e
1179
+ ensure
1180
+ @layout.screen.disable_raw_mode
1181
+ end
1182
+
1183
+ # Handle keyboard input - delegate to InputArea or InlineInput
1184
+ # @param key [Symbol, String, Hash] Key input or rapid input hash
1185
+ def handle_key(key)
1186
+ # If in fullscreen mode, only handle Ctrl+O to exit
1187
+ if @layout.fullscreen_mode?
1188
+ if key == :ctrl_o
1189
+ # Signal the real-time refresh thread to stop gracefully, then join it.
1190
+ # Avoid Thread#kill which can interrupt the thread mid-render and
1191
+ # leave @render_mutex permanently locked.
1192
+ stop_fullscreen_refresh_thread
1193
+ @layout.exit_fullscreen
1194
+ # Restore main screen content after returning from alternate buffer
1195
+ @layout.rerender_all
1196
+ end
1197
+ return
1198
+ end
1199
+
1200
+ # If InlineInput is active, delegate to it
1201
+ if @inline_input&.active?
1202
+ handle_inline_input_key(key)
1203
+ return
1204
+ end
1205
+
1206
+ result = @input_area.handle_key(key)
1207
+
1208
+ # Handle height change first
1209
+ if result[:height_changed]
1210
+ @layout.recalculate_layout
1211
+ end
1212
+
1213
+ # Handle actions
1214
+ case result[:action]
1215
+ when :submit
1216
+ handle_submit(result[:data])
1217
+ when :exit
1218
+ stop
1219
+ exit(0)
1220
+ when :interrupt
1221
+ # Stop all active progress indicators (ticker threads + entries).
1222
+ # An interrupt may happen at any point in a nested flow (e.g.
1223
+ # idle compression during a task); finish the whole stack from
1224
+ # top to bottom so nothing is left running.
1225
+ interrupt_all_progress
1226
+
1227
+ # Check if input area has content
1228
+ input_was_empty = @input_area.empty?
1229
+
1230
+ # Notify CLI to handle interrupt (stop agent or exit)
1231
+ @interrupt_callback&.call(input_was_empty: input_was_empty)
1232
+ when :clear_output
1233
+ # Pass to callback with data for display
1234
+ @input_callback&.call("/clear", [], display: result[:data][:display])
1235
+ when :scroll_up
1236
+ @layout.scroll_output_up
1237
+ when :scroll_down
1238
+ @layout.scroll_output_down
1239
+ when :help
1240
+ # Pass to callback with data for display
1241
+ @input_callback&.call("/help", [], display: result[:data][:display])
1242
+ when :toggle_mode
1243
+ toggle_mode
1244
+ when :toggle_expand
1245
+ # If there's command output available, show it; otherwise show diff
1246
+ if @stdout_lines && !@stdout_lines.empty?
1247
+ show_command_output
1248
+ else
1249
+ redisplay_diff
1250
+ end
1251
+ when :time_machine
1252
+ # Trigger time machine callback
1253
+ @time_machine_callback&.call
1254
+ end
1255
+
1256
+ # Always re-render input area after key handling
1257
+ @layout.render_input
1258
+ end
1259
+
1260
+ # Handle key input for InlineInput
1261
+ def handle_inline_input_key(key)
1262
+ # Get old line count BEFORE modification
1263
+ old_line_count = @inline_input.line_count
1264
+
1265
+ result = @inline_input.handle_key(key)
1266
+
1267
+ case result[:action]
1268
+ when :update
1269
+ # Update the output area with current input (considering wrapped lines)
1270
+ @layout.update_last_line(@inline_input.render, old_line_count)
1271
+ # Position cursor for inline input
1272
+ @layout.position_inline_input_cursor(@inline_input)
1273
+ when :submit, :cancel
1274
+ # InlineInput is done, will be cleaned up by request_confirmation after collect returns
1275
+ # Don't render anything here - let request_confirmation handle cleanup
1276
+ return
1277
+ when :toggle_expand
1278
+ # If there's command output available, show it; otherwise show diff
1279
+ if @stdout_lines && !@stdout_lines.empty?
1280
+ show_command_output
1281
+ else
1282
+ redisplay_diff
1283
+ end
1284
+ when :toggle_mode
1285
+ # Update mode and session bar info, but don't render yet
1286
+ current_mode = @config[:mode]
1287
+ new_mode = case current_mode.to_s
1288
+ when /confirm_safes/
1289
+ "auto_approve"
1290
+ when /auto_approve/
1291
+ "confirm_safes"
1292
+ else
1293
+ "auto_approve"
1294
+ end
1295
+
1296
+ @config[:mode] = new_mode
1297
+ @mode_toggle_callback&.call(new_mode)
1298
+
1299
+ # Update session bar data (will be rendered by request_confirmation's render_all)
1300
+ @input_area.update_sessionbar(
1301
+ session_id: @session_id,
1302
+ working_dir: @config[:working_dir],
1303
+ mode: @config[:mode],
1304
+ model: @config[:model],
1305
+ tasks: @tasks_count
1306
+ )
1307
+ end
1308
+ end
1309
+
1310
+ # Handle submit action
1311
+ private def handle_submit(data)
1312
+ # If any progress indicator is currently active (e.g. an idle-compression
1313
+ # :quiet handle running in the background), finish them all top-to-bottom
1314
+ # so no ticker thread is left writing into the buffer while we render
1315
+ # the new user message. Each handle's finish() renders its own final
1316
+ # frame with elapsed time, so the user still sees a summary.
1317
+ interrupt_all_progress if progress_active?
1318
+
1319
+ # Also clear stdout buffer used by Ctrl+O (unrelated to progress, but
1320
+ # we don't want stale command output carried across user turns).
1321
+ @stdout_lines = nil
1322
+
1323
+ # Render user message immediately before running agent
1324
+ unless data[:text].empty? && data[:files].empty?
1325
+ output = @renderer.render_user_message(data[:text], files: data[:files])
1326
+ append_output(output)
1327
+ end
1328
+
1329
+ # Then call callback (allows interrupting previous agent before processing new input)
1330
+ @input_callback&.call(data[:text], data[:files])
1331
+ end
1332
+
1333
+ # Show configuration modal dialog with multi-model support
1334
+ # @param current_config [Octo::AgentConfig] Current configuration object
1335
+ # @return [Hash, nil] Hash with updated config values, or nil if cancelled
1336
+ public def show_config_modal(current_config, test_callback: nil)
1337
+ modal = Components::ModalComponent.new
1338
+
1339
+ loop do
1340
+ # Build menu choices
1341
+ choices = []
1342
+
1343
+ # Add model list
1344
+ current_config.models.each_with_index do |model, idx|
1345
+ is_current = (idx == current_config.current_model_index)
1346
+ model_name = model["model"] || "unnamed"
1347
+ masked_key = mask_api_key(model["api_key"])
1348
+
1349
+ # Add type badge if present
1350
+ type_badge = case model["type"]
1351
+ when "default" then "[default] "
1352
+ when "lite" then "[lite] "
1353
+ else ""
1354
+ end
1355
+
1356
+ display_name = "#{type_badge}#{model_name} (#{masked_key})"
1357
+ choices << {
1358
+ name: display_name,
1359
+ value: { action: :switch, model_id: model["id"] }
1360
+ }
1361
+ end
1362
+
1363
+ # Add action buttons
1364
+ choices << { name: "─" * 50, disabled: true }
1365
+ choices << { name: "[+] Add New Model", value: { action: :add } }
1366
+ if current_config.models.length > 0
1367
+ choices << { name: "[*] Edit Current Model", value: { action: :edit } }
1368
+ choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1369
+ end
1370
+ choices << { name: "[X] Close", value: { action: :close } }
1371
+
1372
+ # Show menu
1373
+ result = modal.show(
1374
+ title: "Model Configuration",
1375
+ choices: choices,
1376
+ on_close: -> { @layout.rerender_all }
1377
+ )
1378
+
1379
+ return nil if result.nil?
1380
+
1381
+ case result[:action]
1382
+ when :switch
1383
+ # Just signal the caller which model to switch to.
1384
+ # All side effects (agent.switch_model_by_id to rebuild the Client,
1385
+ # set_default_model_by_id, persistence) are done by the CLI layer
1386
+ # in handle_config_command — this keeps show_config_modal a pure
1387
+ # UI component and avoids the modal half-mutating config while
1388
+ # the agent's @client still points at the old credentials.
1389
+ return { action: :switch, model_id: result[:model_id] }
1390
+ when :add
1391
+ new_model = show_model_edit_form(nil, test_callback: test_callback)
1392
+ if new_model
1393
+ # Determine anthropic_format based on provider
1394
+ # For Anthropic provider, use Anthropic API format
1395
+ anthropic_format = new_model[:provider] == "anthropic"
1396
+
1397
+ current_config.add_model(
1398
+ model: new_model[:model],
1399
+ api_key: new_model[:api_key],
1400
+ base_url: new_model[:base_url],
1401
+ anthropic_format: anthropic_format
1402
+ )
1403
+ # Hand off the new model's stable id to the caller. CLI layer
1404
+ # decides whether to switch to it / mark default / persist.
1405
+ new_id = current_config.models.last["id"]
1406
+ return { action: :add, model_id: new_id }
1407
+ end
1408
+ when :edit
1409
+ current_model = current_config.current_model
1410
+ edited = show_model_edit_form(current_model, test_callback: test_callback)
1411
+ if edited
1412
+ # Update current model in place (keep anthropic_format unchanged).
1413
+ # Because we mutate the same hash that's in @models, the model's
1414
+ # stable id is preserved — the caller will rebuild the agent's
1415
+ # Client by calling agent.switch_model_by_id with the same id,
1416
+ # which reruns the Bedrock/anthropic/api_key detection.
1417
+ current_model["api_key"] = edited[:api_key]
1418
+ current_model["model"] = edited[:model]
1419
+ current_model["base_url"] = edited[:base_url]
1420
+ return { action: :edit, model_id: current_model["id"] }
1421
+ end
1422
+ when :delete
1423
+ if current_config.models.length <= 1
1424
+ # Can't delete - show error and continue
1425
+ next
1426
+ end
1427
+
1428
+ # Delete current model — this clears @current_model_id so the
1429
+ # next current_model lookup picks type:default or index fallback.
1430
+ current_config.remove_model(current_config.current_model_index)
1431
+ # New current model id after deletion (may be nil briefly; resolved
1432
+ # on next current_model call, which also re-anchors @current_model_id).
1433
+ new_current = current_config.current_model
1434
+ return { action: :delete, model_id: new_current && new_current["id"] }
1435
+ when :close
1436
+ # Just close the modal
1437
+ return nil
1438
+ end
1439
+ end
1440
+ end
1441
+
1442
+ # Show time machine menu for task undo/redo
1443
+ # @param history [Array<Hash>] Task history with format: [{task_id, summary, status, has_branches}]
1444
+ # @return [Integer, nil] Selected task ID or nil if cancelled
1445
+ public def show_time_machine_menu(history)
1446
+ modal = Components::ModalComponent.new
1447
+
1448
+ # Build menu choices from history
1449
+ choices = history.map do |task|
1450
+ # Build visual indicator
1451
+ indicator = if task[:status] == :current
1452
+ "→ " # Current task
1453
+ elsif task[:status] == :future
1454
+ "↯ " # Future task (after undo)
1455
+ else
1456
+ " " # Past task
1457
+ end
1458
+
1459
+ # Add branch indicator
1460
+ indicator += "⎇ " if task[:has_branches]
1461
+
1462
+ # Truncate summary to fit on screen
1463
+ max_summary_length = 60
1464
+ summary = task[:summary]
1465
+ if summary.length > max_summary_length
1466
+ summary = summary[0...max_summary_length] + "..."
1467
+ end
1468
+
1469
+ {
1470
+ name: "#{indicator}Task #{task[:task_id]}: #{summary}",
1471
+ value: task[:task_id]
1472
+ }
1473
+ end
1474
+
1475
+ # Show modal
1476
+ result = modal.show(
1477
+ title: "Time Machine - Select Task to Navigate",
1478
+ choices: choices,
1479
+ on_close: -> { @layout.rerender_all }
1480
+ )
1481
+
1482
+ result # Return selected task_id or nil
1483
+ end
1484
+
1485
+ # Show form for editing a model
1486
+ # @param model [Hash, nil] Existing model hash or nil for new model
1487
+ # @return [Hash, nil] Updated model hash or nil if cancelled
1488
+ private def show_model_edit_form(model, test_callback: nil)
1489
+ modal = Components::ModalComponent.new
1490
+
1491
+ is_new = model.nil?
1492
+ model ||= {}
1493
+
1494
+ # For new models, show provider selection first
1495
+ selected_provider = nil
1496
+ if is_new
1497
+ # Build provider choices
1498
+ provider_choices = Octo::Providers.list.map do |id, name|
1499
+ { name: name, value: id }
1500
+ end
1501
+ provider_choices << { name: "─" * 40, disabled: true }
1502
+ provider_choices << { name: "Custom (manual configuration)", value: "custom" }
1503
+
1504
+ # Show provider selection
1505
+ selected_provider = modal.show(
1506
+ title: "Select Provider",
1507
+ choices: provider_choices,
1508
+ on_close: -> { @layout.rerender_all }
1509
+ )
1510
+
1511
+ # User cancelled
1512
+ return nil if selected_provider.nil?
1513
+ end
1514
+
1515
+ # Prepare masked API key for display
1516
+ masked_key = mask_api_key(model["api_key"])
1517
+
1518
+ # Pre-fill values from provider preset if selected
1519
+ provider_preset = nil
1520
+ if selected_provider && selected_provider != "custom"
1521
+ provider_preset = Octo::Providers.get(selected_provider)
1522
+ end
1523
+
1524
+ # Get default values from provider or existing model
1525
+ default_model = provider_preset ? provider_preset["default_model"] : model["model"]
1526
+ default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
1527
+ default_api_key = model["api_key"] || ""
1528
+
1529
+ # Define fields
1530
+ fields = [
1531
+ {
1532
+ name: :api_key,
1533
+ label: "API Key #{is_new ? '' : "(current: #{masked_key})"}:",
1534
+ default: "",
1535
+ mask: true
1536
+ },
1537
+ {
1538
+ name: :model,
1539
+ label: "Model #{is_new && default_model ? "(default: #{default_model})" : (is_new ? '' : "(current: #{model['model']})")}:",
1540
+ default: default_model || ""
1541
+ },
1542
+ {
1543
+ name: :base_url,
1544
+ label: "Base URL #{is_new && default_base_url ? "(default: #{default_base_url})" : (is_new ? '' : "(current: #{model['base_url']})")}:",
1545
+ default: default_base_url || ""
1546
+ }
1547
+ ]
1548
+
1549
+ # Create validator if test_callback provided
1550
+ validator = if test_callback
1551
+ lambda do |values|
1552
+ # Merge values: use user input if provided, otherwise keep existing model value
1553
+ api_key = values[:api_key].to_s.empty? ? model["api_key"] : values[:api_key]
1554
+ model_name = values[:model].to_s.empty? ? model["model"] : values[:model]
1555
+ base_url = values[:base_url].to_s.empty? ? model["base_url"] : values[:base_url]
1556
+ anthropic_format = model["anthropic_format"] # Not editable in form, use model's value
1557
+
1558
+ test_config_values = {
1559
+ "api_key" => api_key,
1560
+ "model" => model_name,
1561
+ "base_url" => base_url,
1562
+ "anthropic_format" => anthropic_format
1563
+ }
1564
+
1565
+ # For new models, require all fields
1566
+ if is_new
1567
+ if test_config_values["api_key"].to_s.empty?
1568
+ return { success: false, error: "API Key is required for new model" }
1569
+ end
1570
+ if test_config_values["model"].to_s.empty?
1571
+ return { success: false, error: "Model name is required" }
1572
+ end
1573
+ if test_config_values["base_url"].to_s.empty?
1574
+ return { success: false, error: "Base URL is required" }
1575
+ end
1576
+ end
1577
+
1578
+ # Create a temporary config for testing
1579
+ temp_config = Octo::AgentConfig.new(models: [test_config_values], current_model_index: 0)
1580
+ test_callback.call(temp_config)
1581
+ end
1582
+ else
1583
+ nil
1584
+ end
1585
+
1586
+ # Determine modal title based on provider
1587
+ modal_title = if is_new && selected_provider && selected_provider != "custom"
1588
+ provider_name = Octo::Providers.get(selected_provider)&.dig("name") || selected_provider
1589
+ "Add #{provider_name} Model"
1590
+ elsif is_new
1591
+ "Add Custom Model"
1592
+ else
1593
+ "Edit Model"
1594
+ end
1595
+
1596
+ # Show modal and collect values
1597
+ result = modal.show(
1598
+ title: modal_title,
1599
+ fields: fields,
1600
+ validator: validator,
1601
+ on_close: -> { @layout.rerender_all }
1602
+ )
1603
+
1604
+ return nil if result.nil?
1605
+
1606
+ # Merge with existing model values or provider defaults
1607
+ {
1608
+ api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
1609
+ model: result[:model].to_s.empty? ? (model["model"] || default_model) : result[:model],
1610
+ base_url: result[:base_url].to_s.empty? ? (model["base_url"] || default_base_url) : result[:base_url],
1611
+ provider: selected_provider
1612
+ }
1613
+ end
1614
+
1615
+ # Mask API key for display
1616
+ private def mask_api_key(api_key)
1617
+ if api_key && !api_key.empty?
1618
+ "#{api_key[0..5]}...#{api_key[-4..]}"
1619
+ else
1620
+ "not set"
1621
+ end
1622
+ end
1623
+ end
1624
+ end
1625
+ end