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,1338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tempfile"
5
+ require_relative "../theme_manager"
6
+ require_relative "../line_editor"
7
+ require_relative "command_suggestions"
8
+ require_relative "../../utils/encoding"
9
+
10
+ module Octo
11
+ module UI2
12
+ module Components
13
+ # InputArea manages the fixed input area at the bottom of the screen
14
+ # Enhanced with multi-line support, image paste, and more
15
+ class InputArea
16
+ include LineEditor
17
+
18
+ # User tips pool - can be extended with more tips over time
19
+ USER_TIPS = [
20
+ "Shift+Tab to toggle permission mode (confirm_safes ⇄ auto_approve)",
21
+ "Ctrl+C to interrupt AI execution or clear input",
22
+ "Shift+Enter to create multi-line input",
23
+ "Ctrl+V to paste images (supports up to 3 images)",
24
+ "Ctrl+D to delete pasted images",
25
+ "Use /clear to restart session, /help for commands"
26
+ ].freeze
27
+
28
+ attr_accessor :row
29
+ attr_reader :cursor_position, :line_index, :files, :tips_message, :tips_type
30
+
31
+ HISTORY_FILE = File.expand_path("~/.octo/cmd_history.json").freeze
32
+ MAX_HISTORY = 100
33
+
34
+ def initialize(row: 0)
35
+ @row = row
36
+ @lines = [""]
37
+ @line_index = 0
38
+ @cursor_position = 0
39
+ @history = load_history
40
+ @history_index = -1
41
+ @pastel = Pastel.new
42
+ @width = TTY::Screen.width
43
+
44
+ @files = []
45
+ @paste_counter = 0
46
+ @paste_placeholders = {}
47
+ @last_ctrl_c_time = nil
48
+ @tips_message = nil
49
+ @tips_type = :info
50
+ @tips_timer = nil
51
+ @last_render_row = nil
52
+
53
+ # User tip (usage suggestion) - separate from system tips
54
+ @user_tip = nil
55
+ @user_tip_timer = nil
56
+ @user_tip_count = 0
57
+
58
+ # Paused state - when InlineInput is active
59
+ @paused = false
60
+
61
+ # Session bar info
62
+ @sessionbar_info = {
63
+ session_id: nil, # Full session id; rendered as first 8 chars (parity with WebUI)
64
+ working_dir: nil,
65
+ mode: nil,
66
+ model: nil,
67
+ tasks: 0,
68
+ status: 'idle' # Workspace status: 'idle' or 'working'
69
+ }
70
+
71
+ # Animation state for working status
72
+ @animation_frame = 0
73
+ @last_animation_update = Time.now
74
+ @working_frames = ["❄", "❅", "❆"]
75
+
76
+ # Command suggestions dropdown
77
+ @command_suggestions = CommandSuggestions.new
78
+ @skill_loader = nil # Will be set via set_skill_loader method
79
+ end
80
+
81
+ # Get current theme from ThemeManager
82
+ def theme
83
+ UI2::ThemeManager.current_theme
84
+ end
85
+
86
+ # Get prompt symbol from theme
87
+ def prompt
88
+ "#{theme.symbol(:user)} "
89
+ end
90
+
91
+ def required_height
92
+ # When paused (InlineInput active), don't take up any space
93
+ return 0 if @paused
94
+
95
+ height = 0
96
+
97
+ # Session bar - calculate actual wrapped height
98
+ height += calculate_sessionbar_height
99
+
100
+ # Separator after session bar
101
+ height += 1
102
+
103
+ # Images
104
+ height += @files.size
105
+
106
+ # Calculate height considering wrapped lines
107
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
108
+ content_width = effective_content_width(@width)
109
+ @lines.each_with_index do |line, idx|
110
+ prefix = if idx == 0
111
+ prompt
112
+ else
113
+ " " * prompt.length
114
+ end
115
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
116
+ available_width = [content_width - prefix_width, 20].max # At least 20 chars
117
+ wrapped_segments = wrap_line(line, available_width)
118
+ height += wrapped_segments.size
119
+ end
120
+
121
+ # Bottom separator
122
+ height += 1
123
+
124
+ # Command suggestions (rendered above input)
125
+ height += @command_suggestions.required_height if @command_suggestions
126
+
127
+ # Tips and user tips
128
+ height += 1 if @tips_message
129
+ height += 1 if @user_tip
130
+
131
+ height
132
+ end
133
+
134
+ # Set skill loader for command suggestions
135
+ # @param skill_loader [Octo::SkillLoader] The skill loader instance
136
+ # @param agent_profile [Octo::AgentProfile, nil] Current agent profile for skill filtering
137
+ def set_skill_loader(skill_loader, agent_profile = nil)
138
+ @skill_loader = skill_loader
139
+ @command_suggestions.load_skill_commands(skill_loader, agent_profile) if skill_loader
140
+ end
141
+
142
+ # Update session bar info
143
+ # @param session_id [String] Full session id (rendered as first 8 chars)
144
+ # @param working_dir [String] Working directory
145
+ # @param mode [String] Permission mode
146
+ # @param model [String] AI model name
147
+ # @param tasks [Integer] Number of completed tasks
148
+ # @param status [String] Workspace status ('idle' or 'working')
149
+ def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, status: nil)
150
+ @sessionbar_info[:session_id] = session_id if session_id
151
+ @sessionbar_info[:working_dir] = working_dir if working_dir
152
+ @sessionbar_info[:mode] = mode if mode
153
+ @sessionbar_info[:model] = model if model
154
+ @sessionbar_info[:tasks] = tasks if tasks
155
+ @sessionbar_info[:status] = status if status
156
+ end
157
+
158
+ def input_buffer
159
+ @lines.join("\n")
160
+ end
161
+
162
+ def handle_key(key)
163
+ # Ignore input when paused (InlineInput is active)
164
+ return { action: nil } if @paused
165
+
166
+ old_height = required_height
167
+
168
+ # Handle command suggestions navigation first if visible
169
+ if @command_suggestions.visible
170
+ case key
171
+ when :up_arrow
172
+ @command_suggestions.select_previous
173
+ return { action: nil }
174
+ when :down_arrow
175
+ @command_suggestions.select_next
176
+ return { action: nil }
177
+ when :enter
178
+ # Accept selected command and submit immediately
179
+ if @command_suggestions.has_suggestions?
180
+ selected = @command_suggestions.selected_command_text
181
+ if selected
182
+ # Replace current input with selected command
183
+ @lines = [selected]
184
+ @line_index = 0
185
+ @cursor_position = selected.length
186
+ @command_suggestions.hide
187
+ # Submit the command immediately
188
+ return handle_enter
189
+ end
190
+ end
191
+ # Fall through to normal enter handling if no suggestion
192
+ when :escape
193
+ @command_suggestions.hide
194
+ return { action: nil }
195
+ when :tab
196
+ # Tab accepts the currently highlighted suggestion
197
+ if @command_suggestions.has_suggestions?
198
+ selected = @command_suggestions.selected_command_text
199
+ if selected
200
+ hint = @command_suggestions.selected_argument_hint
201
+ completed = "#{selected} "
202
+ @lines = [completed]
203
+ @line_index = 0
204
+ @cursor_position = completed.length
205
+ @command_suggestions.hide
206
+ # Show argument hint as a tip if available
207
+ set_tips("Usage: #{selected} #{hint}", type: :info) if hint && !hint.empty?
208
+ return { action: nil }
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ # Tab with no visible suggestions: trigger slash-command completion
215
+ if key == :tab
216
+ trigger_tab_completion
217
+ return { action: nil }
218
+ end
219
+
220
+ result = case key
221
+ when Hash
222
+ if key[:type] == :rapid_input
223
+ insert_text(key[:text])
224
+ clear_tips
225
+ update_command_suggestions
226
+ end
227
+ { action: nil }
228
+ when :enter then handle_enter
229
+ when :newline then newline; { action: nil }
230
+ when :backspace
231
+ backspace
232
+ update_command_suggestions
233
+ { action: nil }
234
+ when :delete
235
+ delete_char
236
+ update_command_suggestions
237
+ { action: nil }
238
+ when :left_arrow, :ctrl_b then cursor_left; { action: nil }
239
+ when :right_arrow, :ctrl_f then cursor_right; { action: nil }
240
+ when :up_arrow then handle_up_arrow
241
+ when :down_arrow then handle_down_arrow
242
+ when :home, :ctrl_a then cursor_home; { action: nil }
243
+ when :end, :ctrl_e then cursor_end; { action: nil }
244
+ when :ctrl_k then kill_to_end; { action: nil }
245
+ when :ctrl_u then kill_to_start; { action: nil }
246
+ when :ctrl_w then kill_word; { action: nil }
247
+ when :ctrl_c then handle_ctrl_c
248
+ when :ctrl_d then handle_ctrl_d
249
+ when :ctrl_v then handle_paste
250
+ when :ctrl_o then { action: :toggle_expand }
251
+ when :shift_tab then { action: :toggle_mode }
252
+ when :escape
253
+ if @command_suggestions.visible
254
+ @command_suggestions.hide
255
+ { action: nil }
256
+ else
257
+ # Trigger time machine when ESC is pressed and suggestions not visible
258
+ { action: :time_machine }
259
+ end
260
+ else
261
+ if key.is_a?(String) && key.length >= 1 && key.ord >= 32
262
+ insert_char(key)
263
+ update_command_suggestions
264
+ end
265
+ { action: nil }
266
+ end
267
+
268
+ new_height = required_height
269
+ if new_height != old_height
270
+ result[:height_changed] = true
271
+ result[:new_height] = new_height
272
+ end
273
+
274
+ result
275
+ end
276
+
277
+ def render(start_row:, width: nil)
278
+ @width = width || TTY::Screen.width
279
+ @last_render_row = start_row # Save for tips auto-clear
280
+
281
+ # When paused, don't render anything (InlineInput is active)
282
+ return if @paused
283
+
284
+ current_row = start_row
285
+
286
+ # Session bar at top
287
+ render_sessionbar(current_row)
288
+ current_row += 1
289
+
290
+ # Separator after session bar
291
+ render_separator(current_row)
292
+ current_row += 1
293
+
294
+ # Files (images / documents)
295
+ @files.each_with_index do |f, idx|
296
+ move_cursor(current_row, 0)
297
+ filename = f[:name] || f["name"] || "file"
298
+ size = f[:size] || f["size"]
299
+ size_str = size ? " #{format_filesize(size)}" : ""
300
+ content = @pastel.dim("[File #{idx + 1}] #{filename}#{size_str} (Ctrl+D to delete)")
301
+ print_with_padding(content)
302
+ current_row += 1
303
+ end
304
+
305
+ # Input lines with auto-wrap support
306
+ current_row = render_input_lines(current_row)
307
+
308
+ # Bottom separator
309
+ render_separator(current_row)
310
+ current_row += 1
311
+
312
+ # Command suggestions (rendered above tips)
313
+ if @command_suggestions && @command_suggestions.visible
314
+ # Render suggestions at current row
315
+ print @command_suggestions.render(row: current_row, col: 0, width: [@width - 4, 60].min)
316
+ current_row += @command_suggestions.required_height
317
+ end
318
+
319
+ # Tips bar (if any)
320
+ if @tips_message
321
+ move_cursor(current_row, 0)
322
+ content = format_tips(@tips_message, @tips_type)
323
+ print_with_padding(content)
324
+ current_row += 1
325
+ end
326
+
327
+ # User tip (if any)
328
+ if @user_tip
329
+ move_cursor(current_row, 0)
330
+ content = format_user_tip(@user_tip)
331
+ print_with_padding(content)
332
+ current_row += 1
333
+ end
334
+
335
+ # Position cursor at current edit position
336
+ position_cursor(start_row)
337
+ flush
338
+ end
339
+
340
+ def position_cursor(start_row)
341
+ # Calculate which wrapped line the cursor is on
342
+ cursor_row = start_row + 2 + @files.size # session_bar + separator + images
343
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
344
+ content_width = effective_content_width(@width)
345
+
346
+ # Add rows for lines before current line
347
+ @lines[0...@line_index].each_with_index do |line, idx|
348
+ prefix = if idx == 0
349
+ prompt
350
+ else
351
+ " " * prompt.length
352
+ end
353
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
354
+ available_width = [content_width - prefix_width, 20].max
355
+ wrapped_segments = wrap_line(line, available_width)
356
+ cursor_row += wrapped_segments.size
357
+ end
358
+
359
+ # Find which wrapped segment of current line contains cursor
360
+ current = current_line
361
+ prefix = if @line_index == 0
362
+ prompt
363
+ else
364
+ " " * prompt.length
365
+ end
366
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
367
+ available_width = [content_width - prefix_width, 20].max
368
+ wrapped_segments = wrap_line(current, available_width)
369
+
370
+ # Find cursor segment and position within segment
371
+ cursor_segment_idx = 0
372
+ cursor_pos_in_segment = @cursor_position
373
+
374
+ wrapped_segments.each_with_index do |segment, idx|
375
+ if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
376
+ cursor_segment_idx = idx
377
+ cursor_pos_in_segment = @cursor_position - segment[:start]
378
+ break
379
+ elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
380
+ # Cursor at very end
381
+ cursor_segment_idx = idx
382
+ cursor_pos_in_segment = segment[:end] - segment[:start]
383
+ break
384
+ end
385
+ end
386
+
387
+ cursor_row += cursor_segment_idx
388
+
389
+ # Calculate display width of text before cursor in this segment
390
+ chars = current.chars
391
+ segment_start = wrapped_segments[cursor_segment_idx][:start]
392
+ text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
393
+ display_width = calculate_display_width(text_in_segment_before_cursor)
394
+
395
+ cursor_col = prefix_width + display_width
396
+ move_cursor(cursor_row, cursor_col)
397
+ end
398
+
399
+ def set_tips(message, type: :info)
400
+ # Cancel existing timer if any
401
+ if @tips_timer&.alive?
402
+ @tips_timer.kill
403
+ end
404
+
405
+ @tips_message = message
406
+ @tips_type = type
407
+
408
+ # Auto-clear tips after 2 seconds
409
+ @tips_timer = Thread.new do
410
+ sleep 2
411
+ # Clear tips from state and screen
412
+ @tips_message = nil
413
+ # Tips row: start_row + session_bar(1) + separator(1) + images + lines + separator(1)
414
+ tips_row = @last_render_row + 2 + @files.size + @lines.size + 1
415
+ move_cursor(tips_row, 0)
416
+ clear_line
417
+ flush
418
+ end
419
+ end
420
+
421
+ def clear_tips
422
+ # Cancel timer if any
423
+ if @tips_timer&.alive?
424
+ @tips_timer.kill
425
+ end
426
+ @tips_message = nil
427
+ end
428
+
429
+ # Show a random user tip with probability and auto-rotation (max 3 tips)
430
+ # @param probability [Float] Probability of showing tip (0.0 to 1.0, default: 0.4)
431
+ # @param rotation_interval [Integer] Seconds between tip rotation (default: 12)
432
+ # @param max_tips [Integer] Maximum number of tips to show before stopping (default: 3)
433
+ def show_user_tip(probability: 0.4, rotation_interval: 12, max_tips: 3)
434
+ # Random chance to show tip
435
+ return unless rand < probability
436
+
437
+ # Stop existing timer if any
438
+ stop_user_tip_timer
439
+
440
+ # Reset counter and pick first random tip
441
+ @user_tip_count = 1
442
+ @user_tip = USER_TIPS.sample
443
+
444
+ # Start rotation timer (will show max_tips total)
445
+ @user_tip_timer = Thread.new do
446
+ while @user_tip_count < max_tips
447
+ sleep rotation_interval
448
+ @user_tip_count += 1
449
+
450
+ # Pick a different tip
451
+ old_tip = @user_tip
452
+ loop do
453
+ @user_tip = USER_TIPS.sample
454
+ break if @user_tip != old_tip || USER_TIPS.size == 1
455
+ end
456
+ end
457
+
458
+ # After showing max_tips, wait then clear
459
+ sleep rotation_interval
460
+ @user_tip = nil
461
+ @user_tip_count = 0
462
+ rescue => e
463
+ # Silently handle thread errors
464
+ end
465
+ end
466
+
467
+ # Clear user tip and stop rotation
468
+ def clear_user_tip
469
+ stop_user_tip_timer
470
+ @user_tip = nil
471
+ @user_tip_count = 0
472
+ end
473
+
474
+ private def stop_user_tip_timer
475
+ if @user_tip_timer&.alive?
476
+ @user_tip_timer.kill
477
+ @user_tip_timer = nil
478
+ end
479
+ end
480
+
481
+ # Pause input area (when InlineInput is active)
482
+ def pause
483
+ @paused = true
484
+ end
485
+
486
+ # Resume input area (when InlineInput is done)
487
+ def resume
488
+ @paused = false
489
+ end
490
+
491
+ # Check if paused
492
+ def paused?
493
+ @paused
494
+ end
495
+
496
+ def current_content
497
+ text = expand_placeholders(@lines.join("\n"))
498
+
499
+ # If both text and images are empty, return empty string
500
+ return "" if text.empty? && @files.empty?
501
+
502
+ # Format user input with color and spacing from theme
503
+ symbol = theme.format_symbol(:user)
504
+ content = theme.format_text(text, :user)
505
+
506
+ result = "\n#{symbol} #{content}\n"
507
+
508
+ # Append file information if present
509
+ if @files.any?
510
+ @files.each_with_index do |f, idx|
511
+ filename = f[:name] || f["name"] || "file"
512
+ result += @pastel.dim(" [File #{idx + 1}] #{filename}") + "\n"
513
+ end
514
+ end
515
+
516
+ result
517
+ end
518
+
519
+ def current_value
520
+ expand_placeholders(@lines.join("\n"))
521
+ end
522
+
523
+ def empty?
524
+ @lines.all?(&:empty?) && @files.empty?
525
+ end
526
+
527
+ def multiline?
528
+ @lines.size > 1
529
+ end
530
+
531
+ def has_images?
532
+ @files.any?
533
+ end
534
+
535
+ def set_prompt(prompt)
536
+ prompt = prompt
537
+ end
538
+
539
+ # --- Public editing methods ---
540
+
541
+ def insert_char(char)
542
+ chars = current_line.chars
543
+ chars.insert(@cursor_position, char)
544
+ @lines[@line_index] = chars.join
545
+ @cursor_position += 1
546
+ end
547
+
548
+ def backspace
549
+ if @cursor_position > 0
550
+ chars = current_line.chars
551
+ chars.delete_at(@cursor_position - 1)
552
+ @lines[@line_index] = chars.join
553
+ @cursor_position -= 1
554
+ elsif @line_index > 0
555
+ prev_line = @lines[@line_index - 1]
556
+ current = @lines[@line_index]
557
+ @lines.delete_at(@line_index)
558
+ @line_index -= 1
559
+ @cursor_position = prev_line.chars.length
560
+ @lines[@line_index] = prev_line + current
561
+ end
562
+ end
563
+
564
+ def delete_char
565
+ chars = current_line.chars
566
+ return if @cursor_position >= chars.length
567
+ chars.delete_at(@cursor_position)
568
+ @lines[@line_index] = chars.join
569
+ end
570
+
571
+ def cursor_left
572
+ @cursor_position = [@cursor_position - 1, 0].max
573
+ end
574
+
575
+ def cursor_right
576
+ @cursor_position = [@cursor_position + 1, current_line.chars.length].min
577
+ end
578
+
579
+ def cursor_home
580
+ @cursor_position = 0
581
+ end
582
+
583
+ def cursor_end
584
+ @cursor_position = current_line.chars.length
585
+ end
586
+
587
+ def clear
588
+ @lines = [""]
589
+ @line_index = 0
590
+ @cursor_position = 0
591
+ @history_index = -1
592
+ @files = []
593
+ @paste_counter = 0
594
+ @paste_placeholders = {}
595
+ clear_tips
596
+ @command_suggestions.hide if @command_suggestions
597
+ end
598
+
599
+ def submit
600
+ text = current_value
601
+ files = @files.dup
602
+ add_to_history(text) unless text.empty?
603
+ clear
604
+ { text: text, files: files }
605
+ end
606
+
607
+ def history_prev
608
+ return if @history.empty?
609
+ if @history_index == -1
610
+ @history_index = @history.size - 1
611
+ else
612
+ @history_index = [@history_index - 1, 0].max
613
+ end
614
+ load_history_entry
615
+ end
616
+
617
+ def history_next
618
+ return if @history_index == -1
619
+ @history_index += 1
620
+ if @history_index >= @history.size
621
+ @history_index = -1
622
+ @lines = [""]
623
+ @line_index = 0
624
+ @cursor_position = 0
625
+ else
626
+ load_history_entry
627
+ end
628
+ end
629
+
630
+
631
+ # Update command suggestions based on current input
632
+ # Shows suggestions when input starts with /
633
+ private def update_command_suggestions
634
+ return unless @command_suggestions
635
+
636
+ current = current_line.strip
637
+
638
+ # Check if we should show suggestions (input starts with /)
639
+ if current.start_with?('/') && @line_index == 0
640
+ # Extract the filter text (everything after /)
641
+ filter_text = current[1..-1] || ""
642
+ @command_suggestions.show(filter_text)
643
+ else
644
+ @command_suggestions.hide
645
+ end
646
+ end
647
+
648
+ # Trigger tab completion: show all commands or filter by current slash input
649
+ # Called when Tab is pressed and no suggestions dropdown is visible
650
+ private def trigger_tab_completion
651
+ return unless @command_suggestions
652
+
653
+ current = current_line.strip
654
+
655
+ if current.empty?
656
+ # Empty input: type "/" and show all commands
657
+ insert_char("/")
658
+ @command_suggestions.show("")
659
+ elsif current.start_with?("/")
660
+ # Already typing a slash command: show/refresh filtered suggestions
661
+ filter_text = current[1..-1] || ""
662
+ @command_suggestions.show(filter_text)
663
+ end
664
+ # Tab on normal text has no effect
665
+ end
666
+
667
+ # Render all input lines with auto-wrap support
668
+ # @param start_row [Integer] Starting row position
669
+ # @return [Integer] Next available row after rendering all lines
670
+ def render_input_lines(start_row)
671
+ current_row = start_row
672
+ # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
673
+ content_width = effective_content_width(@width)
674
+
675
+ @lines.each_with_index do |line, line_idx|
676
+ prefix = calculate_line_prefix(line_idx)
677
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
678
+ available_width = content_width - prefix_width
679
+ wrapped_segments = wrap_line(line, available_width)
680
+
681
+ wrapped_segments.each_with_index do |segment_info, wrap_idx|
682
+ content = render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
683
+ move_cursor(current_row, 0)
684
+ print_with_padding(content)
685
+ current_row += 1
686
+ end
687
+ end
688
+
689
+ current_row
690
+ end
691
+
692
+ # Calculate the prefix (prompt or indent) for a given line index
693
+ # @param line_idx [Integer] Index of the line
694
+ # @return [String] Prefix string (with formatting)
695
+ private def calculate_line_prefix(line_idx)
696
+ if line_idx == 0
697
+ theme.format_symbol(:user) + " "
698
+ else
699
+ " " * prompt.length
700
+ end
701
+ end
702
+
703
+ # Render a single segment of a line (handling cursor and wrapping)
704
+ # @param line [String] Full line text
705
+ # @param line_idx [Integer] Index of the line in @lines
706
+ # @param segment_info [Hash] Segment information from wrap_line
707
+ # @param wrap_idx [Integer] Index of this segment in wrapped segments
708
+ # @param prefix [String] Line prefix (prompt or indent)
709
+ # @param prefix_width [Integer] Display width of the prefix
710
+ # @return [String] Formatted content for this segment
711
+ private def render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
712
+ segment_text = segment_info[:text]
713
+ segment_start = segment_info[:start]
714
+ segment_end = segment_info[:end]
715
+
716
+ is_current_line = (line_idx == @line_index)
717
+ is_first_segment = (wrap_idx == 0)
718
+
719
+ # Determine the line prefix
720
+ line_prefix = if is_first_segment
721
+ prefix
722
+ else
723
+ " " * prefix_width # Continuation indent
724
+ end
725
+
726
+ # Render the segment content (with or without cursor)
727
+ segment_content = if is_current_line
728
+ render_line_segment_with_cursor(line, segment_start, segment_end)
729
+ else
730
+ theme.format_text(segment_text, :user)
731
+ end
732
+
733
+ "#{line_prefix}#{segment_content}"
734
+ end
735
+
736
+ # Wrap a line into multiple segments based on available width
737
+ # Considers display width of characters (multi-byte characters like Chinese)
738
+ # @param line [String] The line to wrap
739
+ # @param max_width [Integer] Maximum display width per wrapped line
740
+ # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
741
+ def wrap_line(line, max_width)
742
+ super(line, max_width)
743
+ end
744
+
745
+ # Calculate display width of a single character
746
+ # @param char [String] Single character
747
+ # @return [Integer] Display width (1 or 2)
748
+ def char_display_width(char)
749
+ super(char)
750
+ end
751
+
752
+ # Strip ANSI escape codes from a string
753
+ # @param text [String] Text with ANSI codes
754
+ # @return [String] Text without ANSI codes
755
+ def strip_ansi_codes(text)
756
+ text.gsub(/\e\[[0-9;]*m/, '')
757
+ end
758
+
759
+ # Print content and pad with spaces to clear any remaining characters from previous render
760
+ # This avoids flickering from clear_line while ensuring old content is erased
761
+ def print_with_padding(content)
762
+ # Calculate visible width (strip ANSI codes for width calculation)
763
+ visible_content = content.gsub(/\e\[[0-9;]*m/, '')
764
+ visible_width = calculate_display_width(visible_content)
765
+
766
+ # IMPORTANT: If content exceeds screen width, truncate to prevent terminal auto-wrap
767
+ if visible_width > @width
768
+ # Content too long - truncate to fit (loses ANSI colors but prevents wrapping)
769
+ truncate_at = 0
770
+ current_width = 0
771
+ visible_content.each_char.with_index do |char, idx|
772
+ char_width = char_display_width(char)
773
+ break if current_width + char_width + 3 > @width # Reserve 3 for "..."
774
+ current_width += char_width
775
+ truncate_at = idx + 1
776
+ end
777
+ print visible_content[0...truncate_at]
778
+ print "..."
779
+ # Pad remaining
780
+ remaining = @width - current_width - 3
781
+ print " " * remaining if remaining > 0
782
+ else
783
+ # Content fits - print normally
784
+ print content
785
+ # Pad with spaces if needed to clear old content
786
+ remaining = @width - visible_width
787
+ print " " * remaining if remaining > 0
788
+ end
789
+ end
790
+
791
+ def handle_enter
792
+ text = current_value.strip
793
+
794
+ # Prepare display content and data BEFORE clearing
795
+ content_to_display = current_content
796
+ result_text = current_value
797
+ result_files = @files.dup
798
+
799
+ # Handle commands (with or without slash)
800
+ if text.start_with?('/')
801
+ # Check if it's a command (single slash followed by English letters only)
802
+ # Paths like /xxx/xxxx should not be treated as commands
803
+ if text =~ /^\/([a-zA-Z-]+)$/
804
+ case text
805
+ when '/clear'
806
+ add_to_history(result_text) unless result_text.empty?
807
+ clear
808
+ return { action: :clear_output, data: { text: result_text, files: result_files, display: content_to_display } }
809
+ when '/help'
810
+ add_to_history(result_text) unless result_text.empty?
811
+ clear
812
+ return { action: :help, data: { text: result_text, files: result_files, display: content_to_display } }
813
+ when '/exit', '/quit'
814
+ return { action: :exit }
815
+ else
816
+ # Let other commands (like skills) pass through to agent
817
+ # Fall through to submit
818
+ end
819
+ end
820
+ # If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
821
+ elsif text == '?'
822
+ add_to_history(result_text) unless result_text.empty?
823
+ clear
824
+ return { action: :help, data: { text: result_text, files: result_files, display: content_to_display } }
825
+ elsif text == 'exit' || text == 'quit'
826
+ return { action: :exit }
827
+ end
828
+
829
+ if text.empty? && @files.empty?
830
+ return { action: nil }
831
+ end
832
+
833
+ add_to_history(result_text) unless result_text.empty?
834
+ clear
835
+
836
+ { action: :submit, data: { text: result_text, files: result_files, display: content_to_display } }
837
+ end
838
+
839
+ def handle_up_arrow
840
+ if multiline?
841
+ unless cursor_up
842
+ history_prev
843
+ end
844
+ else
845
+ # Navigate history when single line (empty or not)
846
+ history_prev
847
+ end
848
+ { action: nil }
849
+ end
850
+
851
+ def handle_down_arrow
852
+ if multiline?
853
+ unless cursor_down
854
+ history_next
855
+ end
856
+ else
857
+ # Navigate history when single line (empty or not)
858
+ history_next
859
+ end
860
+ { action: nil }
861
+ end
862
+
863
+ def handle_ctrl_c
864
+ { action: :interrupt }
865
+ end
866
+
867
+ def handle_ctrl_d
868
+ if has_images?
869
+ if @files.size == 1
870
+ @files.clear
871
+ else
872
+ @files.shift
873
+ end
874
+ clear_tips
875
+ { action: nil }
876
+ elsif empty?
877
+ { action: :exit }
878
+ else
879
+ { action: nil }
880
+ end
881
+ end
882
+
883
+ def handle_paste
884
+ pasted = paste_from_clipboard
885
+ if pasted[:type] == :image
886
+ path = pasted[:path]
887
+ mime_type = pasted[:mime_type] || "image/png"
888
+ size = File.exist?(path) ? File.size(path) : 0
889
+ @files << { name: File.basename(path), mime_type: mime_type, path: path, size: size }
890
+ clear_tips
891
+ else
892
+ insert_text(pasted[:text])
893
+ clear_tips
894
+ end
895
+ { action: nil }
896
+ end
897
+
898
+ def insert_text(text)
899
+ return if text.nil? || text.empty?
900
+
901
+ text_lines = text.split(/\r\n|\r|\n/)
902
+
903
+ if text_lines.size > 1
904
+ @paste_counter += 1
905
+ placeholder = "[##{@paste_counter} Paste Text]"
906
+ @paste_placeholders[placeholder] = text
907
+
908
+ chars = current_line.chars
909
+ chars.insert(@cursor_position, *placeholder.chars)
910
+ @lines[@line_index] = chars.join
911
+ @cursor_position += placeholder.length
912
+ else
913
+ chars = current_line.chars
914
+ text.chars.each_with_index do |c, i|
915
+ chars.insert(@cursor_position + i, c)
916
+ end
917
+ @lines[@line_index] = chars.join
918
+ @cursor_position += text.length
919
+ end
920
+ end
921
+
922
+ def newline
923
+ chars = current_line.chars
924
+ @lines[@line_index] = chars[0...@cursor_position].join
925
+ @lines.insert(@line_index + 1, chars[@cursor_position..-1]&.join || "")
926
+ @line_index += 1
927
+ @cursor_position = 0
928
+ end
929
+
930
+ def cursor_up
931
+ return false if @line_index == 0
932
+ @line_index -= 1
933
+ @cursor_position = [@cursor_position, current_line.chars.length].min
934
+ true
935
+ end
936
+
937
+ def cursor_down
938
+ return false if @line_index >= @lines.size - 1
939
+ @line_index += 1
940
+ @cursor_position = [@cursor_position, current_line.chars.length].min
941
+ true
942
+ end
943
+
944
+ def kill_to_end
945
+ chars = current_line.chars
946
+ @lines[@line_index] = chars[0...@cursor_position].join
947
+ end
948
+
949
+ def kill_to_start
950
+ chars = current_line.chars
951
+ @lines[@line_index] = chars[@cursor_position..-1]&.join || ""
952
+ @cursor_position = 0
953
+ end
954
+
955
+ def kill_word
956
+ chars = current_line.chars
957
+ pos = @cursor_position - 1
958
+
959
+ while pos >= 0 && chars[pos] =~ /\s/
960
+ pos -= 1
961
+ end
962
+ while pos >= 0 && chars[pos] =~ /\S/
963
+ pos -= 1
964
+ end
965
+
966
+ delete_start = pos + 1
967
+ chars.slice!(delete_start...@cursor_position)
968
+ @lines[@line_index] = chars.join
969
+ @cursor_position = delete_start
970
+ end
971
+
972
+ def load_history_entry
973
+ return unless @history_index >= 0 && @history_index < @history.size
974
+ entry = @history[@history_index]
975
+ @lines = entry.split("\n")
976
+ @lines = [""] if @lines.empty?
977
+ @line_index = @lines.size - 1
978
+ @cursor_position = current_line.chars.length
979
+ end
980
+
981
+ def add_to_history(entry)
982
+ return if @history.last == entry
983
+ @history << entry
984
+ @history = @history.last(MAX_HISTORY) if @history.size > MAX_HISTORY
985
+ save_history
986
+ end
987
+
988
+ def load_history
989
+ return [] unless File.exist?(HISTORY_FILE)
990
+ JSON.parse(File.read(HISTORY_FILE))
991
+ rescue JSON::ParserError
992
+ []
993
+ end
994
+
995
+ def save_history
996
+ File.write(HISTORY_FILE, JSON.pretty_generate(@history))
997
+ rescue => e
998
+ # Silently fail if we can't write history
999
+ end
1000
+
1001
+ def paste_from_clipboard
1002
+ case RbConfig::CONFIG["host_os"]
1003
+ when /darwin/i
1004
+ paste_from_clipboard_macos
1005
+ when /linux/i
1006
+ paste_from_clipboard_linux
1007
+ else
1008
+ { type: :text, text: "" }
1009
+ end
1010
+ end
1011
+
1012
+ def paste_from_clipboard_macos
1013
+ has_image = system("osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'on error' -e 'return false' -e 'end try' >/dev/null 2>&1")
1014
+
1015
+ if has_image
1016
+ temp_dir = Dir.tmpdir
1017
+ temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
1018
+ temp_path = File.join(temp_dir, temp_filename)
1019
+
1020
+ script = <<~APPLESCRIPT
1021
+ set png_data to the clipboard as «class PNGf»
1022
+ set the_file to open for access POSIX file "#{temp_path}" with write permission
1023
+ write png_data to the_file
1024
+ close access the_file
1025
+ APPLESCRIPT
1026
+
1027
+ success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
1028
+
1029
+ if success && File.exist?(temp_path) && File.size(temp_path) > 0
1030
+ return { type: :image, path: temp_path }
1031
+ end
1032
+ end
1033
+
1034
+ text = `pbpaste 2>/dev/null`.to_s
1035
+ text = Octo::Utils::Encoding.to_utf8(text)
1036
+ { type: :text, text: text }
1037
+ rescue => e
1038
+ { type: :text, text: "" }
1039
+ end
1040
+
1041
+ def paste_from_clipboard_linux
1042
+ if system("which xclip >/dev/null 2>&1")
1043
+ text = `xclip -selection clipboard -o 2>/dev/null`.to_s
1044
+ text = Octo::Utils::Encoding.to_utf8(text)
1045
+ { type: :text, text: text }
1046
+ elsif system("which xsel >/dev/null 2>&1")
1047
+ text = `xsel --clipboard --output 2>/dev/null`.to_s
1048
+ text = Octo::Utils::Encoding.to_utf8(text)
1049
+ { type: :text, text: text }
1050
+ else
1051
+ { type: :text, text: "" }
1052
+ end
1053
+ rescue => e
1054
+ { type: :text, text: "" }
1055
+ end
1056
+
1057
+ def current_line
1058
+ @lines[@line_index] || ""
1059
+ end
1060
+
1061
+ def expand_placeholders(text)
1062
+ super(text, @paste_placeholders)
1063
+ end
1064
+
1065
+ def render_line_with_cursor(line)
1066
+ chars = line.chars
1067
+ before_cursor = chars[0...@cursor_position].join
1068
+ cursor_char = chars[@cursor_position] || " "
1069
+ after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
1070
+
1071
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
1072
+ end
1073
+
1074
+ # Render a segment of a line with cursor if cursor is in this segment
1075
+ # Applies theme colors to the text
1076
+ # @param line [String] Full line text
1077
+ # @param segment_start [Integer] Start position of segment in line (char index)
1078
+ # @param segment_end [Integer] End position of segment in line (char index)
1079
+ # @return [String] Rendered segment with cursor and theme colors applied
1080
+ def render_line_segment_with_cursor(line, segment_start, segment_end)
1081
+ chars = line.chars
1082
+ segment_chars = chars[segment_start...segment_end]
1083
+
1084
+ # Check if cursor is in this segment
1085
+ if @cursor_position >= segment_start && @cursor_position < segment_end
1086
+ # Cursor is in this segment
1087
+ cursor_pos_in_segment = @cursor_position - segment_start
1088
+ before_cursor = segment_chars[0...cursor_pos_in_segment].join
1089
+ cursor_char = segment_chars[cursor_pos_in_segment] || " "
1090
+ after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
1091
+
1092
+ # Apply theme color to text parts, keep cursor highlight as is
1093
+ "#{theme.format_text(before_cursor, :user)}#{@pastel.on_white(@pastel.black(cursor_char))}#{theme.format_text(after_cursor, :user)}"
1094
+ elsif @cursor_position == segment_end && segment_end == line.length
1095
+ # Cursor is at the very end of the line, show it in last segment
1096
+ segment_text = segment_chars.join
1097
+ "#{theme.format_text(segment_text, :user)}#{@pastel.on_white(@pastel.black(' '))}"
1098
+ else
1099
+ # Cursor is not in this segment, apply theme color
1100
+ theme.format_text(segment_chars.join, :user)
1101
+ end
1102
+ end
1103
+
1104
+ # Render a separator line (ensures it doesn't exceed screen width)
1105
+ # @param row [Integer] Row position to render
1106
+ def render_separator(row)
1107
+ move_cursor(row, 0)
1108
+ # Ensure separator doesn't exceed screen width to prevent wrapping
1109
+ separator_width = [@width, 1].max
1110
+ content = @pastel.dim("─" * separator_width)
1111
+ print content
1112
+ # Clear any remaining space
1113
+ remaining = @width - separator_width
1114
+ print " " * remaining if remaining > 0
1115
+ end
1116
+
1117
+ # Render session bar with wrapping support
1118
+ # @param row [Integer] Starting row position
1119
+ # @return [Integer] Number of rows actually used
1120
+ def render_sessionbar(row)
1121
+ move_cursor(row, 0)
1122
+
1123
+ # If no sessionbar info, just render a separator
1124
+ unless @sessionbar_info[:working_dir]
1125
+ separator_width = [@width, 1].max
1126
+ content = @pastel.dim("─" * separator_width)
1127
+ print content
1128
+ remaining = @width - separator_width
1129
+ print " " * remaining if remaining > 0
1130
+ return 1
1131
+ end
1132
+
1133
+ session_line = build_sessionbar_content
1134
+
1135
+ # IMPORTANT: Always use print_with_padding which handles truncation
1136
+ # to prevent terminal auto-wrap
1137
+ print_with_padding(session_line)
1138
+ 1
1139
+ end
1140
+
1141
+ # Build the session bar content string
1142
+ # @return [String] Formatted session bar content
1143
+ private def build_sessionbar_content
1144
+ parts = []
1145
+ separator = @pastel.dim(" │ ")
1146
+
1147
+ # Workspace status with animation
1148
+ if @sessionbar_info[:status]
1149
+ status_color = status_color_for(@sessionbar_info[:status])
1150
+ status_indicator = get_status_indicator(@sessionbar_info[:status], status_color)
1151
+ parts << "#{status_indicator} #{@pastel.public_send(status_color, @sessionbar_info[:status])}"
1152
+ end
1153
+
1154
+ # Session id — first 8 chars (parity with WebUI #sib-id)
1155
+ if @sessionbar_info[:session_id]
1156
+ sid_short = @sessionbar_info[:session_id].to_s[0, 8]
1157
+ parts << theme.format_text(sid_short, :statusbar_secondary) unless sid_short.empty?
1158
+ end
1159
+
1160
+ # Working directory (shortened if too long)
1161
+ if @sessionbar_info[:working_dir]
1162
+ dir_display = shorten_path(@sessionbar_info[:working_dir])
1163
+ parts << theme.format_text(dir_display, :statusbar_path)
1164
+ end
1165
+
1166
+ # Permission mode
1167
+ if @sessionbar_info[:mode]
1168
+ mode_color = mode_color_for(@sessionbar_info[:mode])
1169
+ parts << @pastel.public_send(mode_color, @sessionbar_info[:mode])
1170
+ end
1171
+
1172
+ # Model
1173
+ if @sessionbar_info[:model]
1174
+ parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
1175
+ end
1176
+
1177
+ # Tasks count
1178
+ parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
1179
+
1180
+ " " + parts.join(separator)
1181
+ end
1182
+
1183
+ # Truncate session bar content to fit within max length
1184
+ # @param content [String] Full session bar content with ANSI codes
1185
+ # @param max_length [Integer] Maximum visible length
1186
+ # @return [String] Truncated content
1187
+ private def truncate_sessionbar_content(content, max_length)
1188
+ # Strip ANSI codes to calculate visible length
1189
+ visible_content = strip_ansi_codes(content)
1190
+ visible_width = calculate_display_width(visible_content)
1191
+
1192
+ return content if visible_width <= max_length
1193
+
1194
+ # Truncate from the end with "..." indicator
1195
+ chars = visible_content.chars
1196
+ current_width = 0
1197
+ truncate_at = 0
1198
+
1199
+ chars.each_with_index do |char, idx|
1200
+ char_width = char_display_width(char)
1201
+ if current_width + char_width + 3 > max_length # Reserve 3 for "..."
1202
+ truncate_at = idx
1203
+ break
1204
+ end
1205
+ current_width += char_width
1206
+ truncate_at = idx + 1
1207
+ end
1208
+
1209
+ # For simplicity with ANSI codes, just show first part + ...
1210
+ # This is a simplified version - proper implementation would preserve ANSI codes
1211
+ visible_content[0...truncate_at] + "..."
1212
+ end
1213
+
1214
+ # Calculate how many rows the session bar will occupy
1215
+ # @return [Integer] Number of rows needed
1216
+ private def calculate_sessionbar_height
1217
+ return 1 unless @sessionbar_info[:working_dir]
1218
+
1219
+ # Session bar always renders on one line (we truncate if needed)
1220
+ 1
1221
+ end
1222
+
1223
+ def shorten_path(path)
1224
+ return path if path.length <= 40
1225
+
1226
+ # Replace home directory with ~
1227
+ home = ENV["HOME"]
1228
+ if home && path.start_with?(home)
1229
+ path = path.sub(home, "~")
1230
+ end
1231
+
1232
+ # If still too long, show last parts
1233
+ if path.length > 40
1234
+ parts = path.split("/")
1235
+ if parts.length > 3
1236
+ ".../" + parts[-3..-1].join("/")
1237
+ else
1238
+ path[0..40] + "..."
1239
+ end
1240
+ else
1241
+ path
1242
+ end
1243
+ end
1244
+
1245
+ def mode_color_for(mode)
1246
+ case mode.to_s
1247
+ when /auto_approve/
1248
+ :magenta
1249
+ when /confirm_safes/
1250
+ :cyan
1251
+ else
1252
+ :white
1253
+ end
1254
+ end
1255
+
1256
+ def status_color_for(status)
1257
+ case status.to_s.downcase
1258
+ when 'idle'
1259
+ :cyan # Use darker cyan for idle state
1260
+ when 'working'
1261
+ :yellow # Use yellow to highlight working state
1262
+ else
1263
+ :cyan
1264
+ end
1265
+ end
1266
+
1267
+ def get_status_indicator(status, color)
1268
+ case status.to_s.downcase
1269
+ when 'working'
1270
+ # Update animation frame if enough time has passed
1271
+ now = Time.now
1272
+ if now - @last_animation_update >= 0.3
1273
+ @animation_frame = (@animation_frame + 1) % @working_frames.length
1274
+ @last_animation_update = now
1275
+ end
1276
+ @pastel.public_send(color, @working_frames[@animation_frame])
1277
+ else
1278
+ @pastel.public_send(color, "●") # Idle indicator with same color as text
1279
+ end
1280
+ end
1281
+
1282
+ def format_tips(message, type)
1283
+ # Limit message length to prevent line wrapping
1284
+ # Reserve space for prefix like "[Warn] " (about 8 chars) and some margin
1285
+ max_length = @width - 10
1286
+ if message.length > max_length
1287
+ message = message[0...(max_length - 3)] + "..."
1288
+ end
1289
+
1290
+ case type
1291
+ when :warning
1292
+ @pastel.dim("[") + @pastel.yellow("Warn") + @pastel.dim("] ") + @pastel.yellow(message)
1293
+ when :error
1294
+ @pastel.dim("[") + @pastel.red("Error") + @pastel.dim("] ") + @pastel.red(message)
1295
+ else
1296
+ @pastel.dim("[") + @pastel.cyan("Info") + @pastel.dim("] ") + @pastel.white(message)
1297
+ end
1298
+ end
1299
+
1300
+ def format_filesize(size)
1301
+ if size < 1024
1302
+ "#{size}B"
1303
+ elsif size < 1024 * 1024
1304
+ "#{(size / 1024.0).round(1)}KB"
1305
+ else
1306
+ "#{(size / 1024.0 / 1024.0).round(1)}MB"
1307
+ end
1308
+ end
1309
+
1310
+ # Format user tip (usage suggestion) with lightbulb icon
1311
+ # @param tip [String] Tip message
1312
+ # @return [String] Formatted tip with styling
1313
+ def format_user_tip(tip)
1314
+ # Limit message length to prevent line wrapping
1315
+ max_length = @width - 5 # Reserve space for icon and margins
1316
+ if tip.length > max_length
1317
+ tip = tip[0...(max_length - 3)] + "..."
1318
+ end
1319
+
1320
+ # Use lightbulb icon and dim cyan color for subtle appearance
1321
+ @pastel.dim(@pastel.cyan("💡 #{tip}"))
1322
+ end
1323
+
1324
+ def move_cursor(row, col)
1325
+ print "\e[#{row + 1};#{col + 1}H"
1326
+ end
1327
+
1328
+ def clear_line
1329
+ print "\e[2K"
1330
+ end
1331
+
1332
+ def flush
1333
+ $stdout.flush
1334
+ end
1335
+ end
1336
+ end
1337
+ end
1338
+ end