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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module UI2
|
|
7
|
+
# An *owned* progress indicator.
|
|
8
|
+
#
|
|
9
|
+
# Why this exists
|
|
10
|
+
# ---------------
|
|
11
|
+
# The previous design had a single, globally-shared spinner slot on
|
|
12
|
+
# UiController (`@progress_id` / `@progress_thread` / `@progress_message`
|
|
13
|
+
# / `@progress_start_time`). Every caller — Agent#run, Agent#think,
|
|
14
|
+
# LlmCaller retry, idle compression, MemoryUpdater — wrote into the
|
|
15
|
+
# same slot and hoped to remember to close it. When control flow was
|
|
16
|
+
# interrupted (user types a new message during idle compression,
|
|
17
|
+
# AgentInterrupted is raised) a ticker thread would be left running
|
|
18
|
+
# and a new spinner would reuse the same entry, producing two
|
|
19
|
+
# concurrent tickers repainting the same line in different colors.
|
|
20
|
+
#
|
|
21
|
+
# In the new design each caller owns a ProgressHandle. The handle
|
|
22
|
+
# encapsulates:
|
|
23
|
+
#
|
|
24
|
+
# - its own OutputBuffer entry id (may become nil while another
|
|
25
|
+
# handle is on top — see "Stack semantics" below);
|
|
26
|
+
# - its own ticker thread (exactly one per handle, stopped and
|
|
27
|
+
# joined on +finish+);
|
|
28
|
+
# - its own message, style, start time;
|
|
29
|
+
#
|
|
30
|
+
# Owners (UiController) keep a stack of live handles and follow the
|
|
31
|
+
# protocol below.
|
|
32
|
+
#
|
|
33
|
+
# Owner protocol
|
|
34
|
+
# --------------
|
|
35
|
+
# An "owner" must respond to three methods:
|
|
36
|
+
#
|
|
37
|
+
# register_progress(handle) -> Integer (entry_id) | nil
|
|
38
|
+
# Called exactly once when the handle starts. The owner pushes
|
|
39
|
+
# the handle onto its stack, creates an OutputBuffer entry, and
|
|
40
|
+
# returns that entry id. Before pushing, the owner may detach
|
|
41
|
+
# the previous top-of-stack (Plan B: its entry is removed from
|
|
42
|
+
# the buffer until the new top finishes).
|
|
43
|
+
#
|
|
44
|
+
# unregister_progress(handle, final_frame:) -> void
|
|
45
|
+
# Called exactly once when the handle finishes. The owner pops
|
|
46
|
+
# the handle from its stack, renders +final_frame+ into the
|
|
47
|
+
# entry (or removes the entry if +final_frame+ is nil), and may
|
|
48
|
+
# reattach the new top-of-stack if one exists.
|
|
49
|
+
#
|
|
50
|
+
# render_frame(handle, frame) -> void
|
|
51
|
+
# Called by the ticker (and by +update+) on every paint. The
|
|
52
|
+
# owner is responsible for ignoring the call if +handle+ is not
|
|
53
|
+
# currently top-of-stack — the handle itself does NOT know about
|
|
54
|
+
# the stack.
|
|
55
|
+
#
|
|
56
|
+
# Stack semantics (Plan B)
|
|
57
|
+
# ------------------------
|
|
58
|
+
# When a new handle is pushed on top of an existing one, the lower
|
|
59
|
+
# handle's OutputBuffer entry is removed (owner calls
|
|
60
|
+
# +__detach_entry!+ on it). When the new top finishes, the owner
|
|
61
|
+
# re-creates an entry for the lower handle and calls
|
|
62
|
+
# +__reattach_entry!+ with the new id. This keeps the visible output
|
|
63
|
+
# clean: exactly one progress line on screen at a time, and no
|
|
64
|
+
# visual "stacking" of frozen progress lines.
|
|
65
|
+
#
|
|
66
|
+
# Thread safety
|
|
67
|
+
# -------------
|
|
68
|
+
# The handle uses a Monitor (reentrant) to serialize state changes
|
|
69
|
+
# between the caller thread and the ticker thread. Public methods
|
|
70
|
+
# (+start+, +update+, +finish+) are safe to call from any thread.
|
|
71
|
+
class ProgressHandle
|
|
72
|
+
# Default tick interval (seconds). Matches the old global spinner
|
|
73
|
+
# cadence. Tests may pass a smaller interval for speed.
|
|
74
|
+
DEFAULT_TICK_INTERVAL = 0.25
|
|
75
|
+
|
|
76
|
+
# Style hint for the renderer. The owner decides what colors to use;
|
|
77
|
+
# the handle only forwards the hint as part of the frame metadata
|
|
78
|
+
# so the renderer can pick between e.g. yellow "working" and gray
|
|
79
|
+
# "quiet" palettes.
|
|
80
|
+
#
|
|
81
|
+
# :primary — foreground task, should also update sessionbar
|
|
82
|
+
# :quiet — background task (idle compression, retries); does
|
|
83
|
+
# NOT bump sessionbar to 'working'
|
|
84
|
+
VALID_STYLES = %i[primary quiet].freeze
|
|
85
|
+
|
|
86
|
+
attr_reader :entry_id, :message, :style, :start_time
|
|
87
|
+
|
|
88
|
+
# Threshold (seconds) below which a +quiet_on_fast_finish+ handle
|
|
89
|
+
# collapses its final frame — i.e. the progress line is REMOVED
|
|
90
|
+
# from the output buffer instead of being kept as a permanent
|
|
91
|
+
# "Executing foo… (0s)" log line. Operations that finish this fast
|
|
92
|
+
# didn't need a spinner in the first place; keeping the final
|
|
93
|
+
# frame would be visual noise.
|
|
94
|
+
FAST_FINISH_THRESHOLD_SECONDS = 2
|
|
95
|
+
|
|
96
|
+
# Show "Thinking for Ns" once the gap since the last LLM stream
|
|
97
|
+
# chunk reaches this many seconds. Bedrock often pauses 5–18s
|
|
98
|
+
# while generating large content blocks (long tool_use JSON in
|
|
99
|
+
# particular); without this hint users assume the agent is stuck.
|
|
100
|
+
IDLE_HINT_THRESHOLD_SECONDS = 2
|
|
101
|
+
|
|
102
|
+
# @param owner [#register_progress, #unregister_progress, #render_frame]
|
|
103
|
+
# @param message [String] Initial progress message.
|
|
104
|
+
# @param style [Symbol] :primary or :quiet (see VALID_STYLES).
|
|
105
|
+
# @param tick_interval [Float] Seconds between auto-renders.
|
|
106
|
+
# @param quiet_on_fast_finish [Boolean] When true and the elapsed
|
|
107
|
+
# time on +finish+ is under FAST_FINISH_THRESHOLD_SECONDS, the
|
|
108
|
+
# owner is told to remove the progress entry (+final_frame: nil+)
|
|
109
|
+
# instead of committing a permanent final frame. This is the
|
|
110
|
+
# preferred mode for tool execution wrappers, where fast tools
|
|
111
|
+
# (edit, write, read) don't need a lingering "Executing edit…
|
|
112
|
+
# (0s)" line after completion.
|
|
113
|
+
# @param clock [#call] Test hook: returns current Time (default Time.now).
|
|
114
|
+
def initialize(owner:, message:, style: :primary, tick_interval: DEFAULT_TICK_INTERVAL, quiet_on_fast_finish: false, clock: -> { Time.now })
|
|
115
|
+
unless VALID_STYLES.include?(style)
|
|
116
|
+
raise ArgumentError, "unknown progress style: #{style.inspect} (valid: #{VALID_STYLES.inspect})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@owner = owner
|
|
120
|
+
@message = message.to_s
|
|
121
|
+
@style = style
|
|
122
|
+
@tick_interval = tick_interval
|
|
123
|
+
@quiet_on_fast_finish = quiet_on_fast_finish
|
|
124
|
+
@clock = clock
|
|
125
|
+
|
|
126
|
+
@entry_id = nil
|
|
127
|
+
@start_time = nil
|
|
128
|
+
@ticker = nil
|
|
129
|
+
@state = :fresh # :fresh → :running → :closed
|
|
130
|
+
@metadata = {}
|
|
131
|
+
@last_chunk_at = nil
|
|
132
|
+
@monitor = Monitor.new
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Start rendering. Registers with the owner (allocating an entry id
|
|
136
|
+
# and pushing onto its stack) and launches the ticker thread.
|
|
137
|
+
#
|
|
138
|
+
# @return [self]
|
|
139
|
+
def start
|
|
140
|
+
@monitor.synchronize do
|
|
141
|
+
return self unless @state == :fresh
|
|
142
|
+
|
|
143
|
+
@state = :running
|
|
144
|
+
@start_time = @clock.call
|
|
145
|
+
@last_chunk_at = @start_time
|
|
146
|
+
@entry_id = @owner.register_progress(self)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Fire one initial frame synchronously so the user sees the
|
|
150
|
+
# spinner immediately — no "blank line for half a second" bug.
|
|
151
|
+
render_now
|
|
152
|
+
|
|
153
|
+
start_ticker
|
|
154
|
+
self
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Change the message or metadata mid-flight. Safe to call from any
|
|
158
|
+
# thread. Triggers an immediate re-render (if top-of-stack; the
|
|
159
|
+
# owner will ignore the call otherwise).
|
|
160
|
+
#
|
|
161
|
+
# @param message [String, nil]
|
|
162
|
+
# @param metadata [Hash] Renderer-specific extras (e.g. retry counts).
|
|
163
|
+
def update(message: nil, metadata: nil)
|
|
164
|
+
@monitor.synchronize do
|
|
165
|
+
return if @state != :running
|
|
166
|
+
@message = message.to_s if message
|
|
167
|
+
if metadata
|
|
168
|
+
@metadata = metadata
|
|
169
|
+
@last_chunk_at = @clock.call
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Stop the ticker, render one final frame, and unregister from the
|
|
175
|
+
# owner. Idempotent — calling twice is a no-op.
|
|
176
|
+
#
|
|
177
|
+
# @param final_message [String, nil] Optional override for the last
|
|
178
|
+
# frame. If nil, the handle composes "<message>… (<elapsed>s)".
|
|
179
|
+
def finish(final_message: nil)
|
|
180
|
+
snapshot = @monitor.synchronize do
|
|
181
|
+
return if @state != :running
|
|
182
|
+
@state = :closed
|
|
183
|
+
{ message: final_message || @message, elapsed: elapsed_seconds }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
stop_ticker
|
|
187
|
+
# Collapse fast-finishers to a removed entry so tools that complete
|
|
188
|
+
# in under FAST_FINISH_THRESHOLD_SECONDS don't leave a permanent
|
|
189
|
+
# "Executing foo… (0s)" line. The owner interprets final_frame: nil
|
|
190
|
+
# as "remove the entry entirely".
|
|
191
|
+
final_frame =
|
|
192
|
+
if @quiet_on_fast_finish && snapshot[:elapsed] < FAST_FINISH_THRESHOLD_SECONDS
|
|
193
|
+
nil
|
|
194
|
+
else
|
|
195
|
+
compose_final_frame(snapshot[:message], snapshot[:elapsed])
|
|
196
|
+
end
|
|
197
|
+
@owner.unregister_progress(self, final_frame: final_frame)
|
|
198
|
+
end
|
|
199
|
+
alias_method :cancel, :finish
|
|
200
|
+
|
|
201
|
+
# True while the ticker thread is alive.
|
|
202
|
+
def ticker_alive?
|
|
203
|
+
t = @ticker
|
|
204
|
+
!!(t && t.alive?)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# True between +start+ and +finish+.
|
|
208
|
+
def running?
|
|
209
|
+
@monitor.synchronize { @state == :running }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Compose the current visual frame. The owner gets this string via
|
|
213
|
+
# +render_frame+ and is responsible for writing it into the entry.
|
|
214
|
+
def current_frame
|
|
215
|
+
@monitor.synchronize do
|
|
216
|
+
compose_frame(@message, elapsed_seconds, @metadata, idle_seconds)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ---- owner-facing hooks (Plan B stack machinery) ----------------
|
|
221
|
+
#
|
|
222
|
+
# These double-underscore methods are part of the owner protocol.
|
|
223
|
+
# They are NOT meant for general callers.
|
|
224
|
+
|
|
225
|
+
# Owner calls this when this handle is being pushed below a new
|
|
226
|
+
# top. The handle loses its OutputBuffer entry until restored.
|
|
227
|
+
def __detach_entry!
|
|
228
|
+
@monitor.synchronize { @entry_id = nil }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Owner calls this when this handle becomes top-of-stack again
|
|
232
|
+
# (the handle above finished). A fresh entry id is supplied.
|
|
233
|
+
def __reattach_entry!(new_entry_id)
|
|
234
|
+
@monitor.synchronize { @entry_id = new_entry_id }
|
|
235
|
+
render_now
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Like __reattach_entry! but skips the render_now hop. Used by the
|
|
239
|
+
# owner when it has just painted a frame into the new entry itself
|
|
240
|
+
# (e.g. while rotating the handle to remain at the buffer tail) and
|
|
241
|
+
# is still inside its own synchronization — calling render_now there
|
|
242
|
+
# would re-enter the owner's mutex.
|
|
243
|
+
def __rebind_entry!(new_entry_id)
|
|
244
|
+
@monitor.synchronize { @entry_id = new_entry_id }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Test hook: force a synchronous render regardless of tick cadence.
|
|
248
|
+
def __force_render!
|
|
249
|
+
render_now
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private def start_ticker
|
|
253
|
+
@ticker = Thread.new do
|
|
254
|
+
Thread.current.name = "progress-ticker-#{object_id}"
|
|
255
|
+
begin
|
|
256
|
+
loop do
|
|
257
|
+
sleep @tick_interval
|
|
258
|
+
break if @monitor.synchronize { @state != :running }
|
|
259
|
+
render_now
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError
|
|
262
|
+
# Ticker must never crash the process — the caller's main
|
|
263
|
+
# thread still owns the real control flow.
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private def stop_ticker
|
|
269
|
+
t = @ticker
|
|
270
|
+
return unless t
|
|
271
|
+
# The loop checks @state on each iteration, so once we're
|
|
272
|
+
# :closed the next wake-up exits cleanly. Give it 1s; if
|
|
273
|
+
# something is stuck, kill as a last resort.
|
|
274
|
+
joined = t.join(1.0)
|
|
275
|
+
t.kill unless joined
|
|
276
|
+
@ticker = nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private def render_now
|
|
280
|
+
frame = current_frame
|
|
281
|
+
@owner.render_frame(self, frame)
|
|
282
|
+
rescue StandardError
|
|
283
|
+
# Rendering must never propagate.
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private def elapsed_seconds
|
|
287
|
+
return 0 unless @start_time
|
|
288
|
+
(@clock.call - @start_time).to_i
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Seconds since the last metadata update (i.e. the last LLM stream
|
|
292
|
+
# chunk that carried token info). Used to surface "Thinking for Ns"
|
|
293
|
+
# in the live frame so users can see the agent isn't stuck even
|
|
294
|
+
# when token counts plateau during long Bedrock content blocks.
|
|
295
|
+
private def idle_seconds
|
|
296
|
+
return 0 unless @last_chunk_at
|
|
297
|
+
(@clock.call - @last_chunk_at).to_i
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Live-frame format:
|
|
301
|
+
# "<message>… (<elapsed>s · ↓N tokens · reasoning…)"
|
|
302
|
+
# The "reasoning" tail only appears once tokens have started
|
|
303
|
+
# streaming AND the gap since the last chunk reaches the threshold
|
|
304
|
+
# — signalling the model is between tool_use blocks doing extended
|
|
305
|
+
# thinking. No seconds shown there to avoid duplicating elapsed;
|
|
306
|
+
# animated dots (1→2→3) provide the "still alive" cue.
|
|
307
|
+
private def compose_frame(message, elapsed, metadata, idle = 0)
|
|
308
|
+
head = message.to_s
|
|
309
|
+
if metadata && (attempt = metadata[:attempt]) && (total = metadata[:total])
|
|
310
|
+
head = "#{head} [#{attempt}/#{total}]"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
token_part = metadata && format_token_progress(metadata)
|
|
314
|
+
|
|
315
|
+
suffix_parts = []
|
|
316
|
+
suffix_parts << "#{elapsed}s" if elapsed > 0
|
|
317
|
+
suffix_parts << token_part if token_part
|
|
318
|
+
if token_part && idle >= IDLE_HINT_THRESHOLD_SECONDS
|
|
319
|
+
suffix_parts << "reasoning #{spinner_frame} "
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
return "#{head}…" if suffix_parts.empty?
|
|
323
|
+
"#{head}… (#{suffix_parts.join(" · ")})"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
327
|
+
SPINNER_INTERVAL_MS = 250
|
|
328
|
+
|
|
329
|
+
private def spinner_frame
|
|
330
|
+
ms = (@clock.call.to_f * 1000).to_i
|
|
331
|
+
SPINNER_FRAMES[(ms / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Render LLM streaming token counts as "↑1.2k ↓234 tokens".
|
|
335
|
+
# When input_tokens is unknown (e.g. OpenAI-compat streaming where
|
|
336
|
+
# prompt_tokens only arrives in the final frame), shows "↑—" so the
|
|
337
|
+
# column doesn't flicker between absent / present.
|
|
338
|
+
private def format_token_progress(metadata)
|
|
339
|
+
output = metadata[:output_tokens]
|
|
340
|
+
return nil if output.nil? || output.to_i <= 0
|
|
341
|
+
"↓ #{compact_count(output.to_i)} tokens"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
private def compact_count(n)
|
|
345
|
+
return n.to_s if n < 1000
|
|
346
|
+
if n < 1_000_000
|
|
347
|
+
k = n / 1000.0
|
|
348
|
+
k >= 10 ? "#{k.to_i}k" : "%.1fk" % k
|
|
349
|
+
else
|
|
350
|
+
m = n / 1_000_000.0
|
|
351
|
+
m >= 10 ? "#{m.to_i}M" : "%.1fM" % m
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Final frame (used by +finish+). Same as +compose_frame+ but we
|
|
356
|
+
# always include elapsed time so the last line carries a duration.
|
|
357
|
+
private def compose_final_frame(message, elapsed)
|
|
358
|
+
"#{message}… (#{elapsed}s)"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
class ProgressIndicator
|
|
5
|
+
def initialize(verbose: false, message: nil)
|
|
6
|
+
@verbose = verbose
|
|
7
|
+
@start_time = nil
|
|
8
|
+
@custom_message = message
|
|
9
|
+
@thinking_verb = message || THINKING_VERBS.sample
|
|
10
|
+
@running = false
|
|
11
|
+
@update_thread = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start
|
|
15
|
+
@start_time = Time.now
|
|
16
|
+
@running = true
|
|
17
|
+
# Save cursor position after the [..] symbol
|
|
18
|
+
print "\e[s" # Save cursor position
|
|
19
|
+
print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt)")
|
|
20
|
+
|
|
21
|
+
# Start background thread to update elapsed time
|
|
22
|
+
@update_thread = Thread.new do
|
|
23
|
+
while @running
|
|
24
|
+
sleep 0.1
|
|
25
|
+
update if @running
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
return unless @start_time
|
|
32
|
+
|
|
33
|
+
elapsed = (Time.now - @start_time).to_i
|
|
34
|
+
print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt · #{elapsed}s)")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def finish
|
|
38
|
+
@running = false
|
|
39
|
+
@update_thread&.join
|
|
40
|
+
# Restore cursor and clear to end of line
|
|
41
|
+
print "\e[u" # Restore cursor position
|
|
42
|
+
print "\e[K" # Clear to end of line
|
|
43
|
+
puts "" # Add newline after finishing
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_thinking_status(text)
|
|
48
|
+
print "\e[u" # Restore cursor position (to after [..] symbol)
|
|
49
|
+
print "\e[K" # Clear to end of line from cursor
|
|
50
|
+
print text
|
|
51
|
+
print " "
|
|
52
|
+
$stdout.flush
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-screen"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require_relative "../utils/encoding"
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
module UI2
|
|
9
|
+
# ScreenBuffer manages terminal screen state and provides low-level rendering primitives
|
|
10
|
+
class ScreenBuffer
|
|
11
|
+
attr_reader :width, :height
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@width = TTY::Screen.width
|
|
15
|
+
@height = TTY::Screen.height
|
|
16
|
+
@buffer = []
|
|
17
|
+
@last_input_time = nil
|
|
18
|
+
@rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
|
|
19
|
+
|
|
20
|
+
# Keep stdin in UTF-8 mode so getc returns complete multi-byte characters (e.g. CJK).
|
|
21
|
+
# Switching to BINARY would cause getc to return one byte at a time, breaking Chinese input.
|
|
22
|
+
$stdin.set_encoding('UTF-8')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Move cursor to specific position (0-indexed)
|
|
26
|
+
# @param row [Integer] Row position
|
|
27
|
+
# @param col [Integer] Column position
|
|
28
|
+
def move_cursor(row, col)
|
|
29
|
+
print "\e[#{row + 1};#{col + 1}H"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Clear screen with different modes:
|
|
33
|
+
# :preserve - clear visible screen, scrollback history preserved (default)
|
|
34
|
+
# :current - cursor to top-left and erase to end, no new scrollback produced
|
|
35
|
+
# :reset - clear visible screen AND scrollback history (full reset)
|
|
36
|
+
# @param mode [Symbol] Clear mode (:preserve, :current, :reset)
|
|
37
|
+
def clear_screen(mode: :preserve)
|
|
38
|
+
case mode
|
|
39
|
+
when :reset
|
|
40
|
+
print "\e[3J" # erase scrollback buffer
|
|
41
|
+
print "\e[H\e[J" # cursor to top-left, erase to end of screen
|
|
42
|
+
when :current
|
|
43
|
+
print "\e[H\e[J" # cursor to top-left, erase to end of screen
|
|
44
|
+
else # :preserve
|
|
45
|
+
print "\e[2J\e[H" # erase visible screen, scrollback preserved
|
|
46
|
+
end
|
|
47
|
+
move_cursor(0, 0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Clear current line
|
|
51
|
+
def clear_line
|
|
52
|
+
print "\e[2K"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clear from cursor to end of line
|
|
56
|
+
def clear_to_eol
|
|
57
|
+
print "\e[K"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Hide cursor
|
|
61
|
+
def hide_cursor
|
|
62
|
+
print "\e[?25l"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Show cursor
|
|
66
|
+
def show_cursor
|
|
67
|
+
print "\e[?25h"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Save cursor position
|
|
71
|
+
def save_cursor
|
|
72
|
+
print "\e[s"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Restore cursor position
|
|
76
|
+
def restore_cursor
|
|
77
|
+
print "\e[u"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Enable alternative screen buffer (like vim/less)
|
|
81
|
+
def enable_alt_screen
|
|
82
|
+
print "\e[?1049h"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Disable alternative screen buffer
|
|
86
|
+
def disable_alt_screen
|
|
87
|
+
print "\e[?1049l"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Set scroll region (DECSTBM - DEC Set Top and Bottom Margins)
|
|
91
|
+
# Content written in this region will scroll, content outside will stay fixed
|
|
92
|
+
# @param top [Integer] Top row (1-indexed)
|
|
93
|
+
# @param bottom [Integer] Bottom row (1-indexed)
|
|
94
|
+
def set_scroll_region(top, bottom)
|
|
95
|
+
print "\e[#{top};#{bottom}r"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reset scroll region to full screen
|
|
99
|
+
def reset_scroll_region
|
|
100
|
+
print "\e[r"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Scroll the scroll region up by n lines
|
|
104
|
+
# @param n [Integer] Number of lines to scroll
|
|
105
|
+
def scroll_up(n = 1)
|
|
106
|
+
print "\e[#{n}S"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Scroll the scroll region down by n lines
|
|
110
|
+
# @param n [Integer] Number of lines to scroll
|
|
111
|
+
def scroll_down(n = 1)
|
|
112
|
+
print "\e[#{n}T"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get current screen dimensions
|
|
116
|
+
def update_dimensions
|
|
117
|
+
@width = TTY::Screen.width
|
|
118
|
+
@height = TTY::Screen.height
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Enable raw mode (disable line buffering)
|
|
122
|
+
def enable_raw_mode
|
|
123
|
+
$stdin.raw!
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Disable raw mode
|
|
127
|
+
def disable_raw_mode
|
|
128
|
+
$stdin.cooked!
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Read a single character without echo
|
|
132
|
+
# @param timeout [Float] Timeout in seconds (nil for blocking)
|
|
133
|
+
# @return [String, nil] Character or nil if timeout
|
|
134
|
+
def read_char(timeout: nil)
|
|
135
|
+
if timeout
|
|
136
|
+
return nil unless IO.select([$stdin], nil, nil, timeout)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
$stdin.getc
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Read a key including special keys (arrows, etc.)
|
|
143
|
+
# @param timeout [Float] Timeout in seconds
|
|
144
|
+
# @return [Symbol, String, Hash, nil] Key symbol, character, or { type: :rapid_input, text: String }
|
|
145
|
+
def read_key(timeout: nil)
|
|
146
|
+
current_time = Time.now.to_f
|
|
147
|
+
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
148
|
+
@last_input_time = current_time
|
|
149
|
+
|
|
150
|
+
char = read_char(timeout: timeout)
|
|
151
|
+
return nil unless char
|
|
152
|
+
|
|
153
|
+
# Convert raw BINARY bytes to valid UTF-8. Invalid/undefined bytes are dropped
|
|
154
|
+
# rather than raising ArgumentError (which would crash the input loop).
|
|
155
|
+
char = safe_to_utf8(char) if char.is_a?(String)
|
|
156
|
+
|
|
157
|
+
# Handle escape sequences for special keys
|
|
158
|
+
if char == "\e"
|
|
159
|
+
# Non-blocking read for escape sequence
|
|
160
|
+
char2 = read_char(timeout: 0.01)
|
|
161
|
+
return :escape unless char2
|
|
162
|
+
|
|
163
|
+
if char2 == "["
|
|
164
|
+
char3 = read_char(timeout: 0.01)
|
|
165
|
+
case char3
|
|
166
|
+
when "A" then return :up_arrow
|
|
167
|
+
when "B" then return :down_arrow
|
|
168
|
+
when "C" then return :right_arrow
|
|
169
|
+
when "D" then return :left_arrow
|
|
170
|
+
when "H" then return :home
|
|
171
|
+
when "F" then return :end
|
|
172
|
+
when "Z" then return :shift_tab
|
|
173
|
+
when "3"
|
|
174
|
+
char4 = read_char(timeout: 0.01)
|
|
175
|
+
return :delete if char4 == "~"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Check if there are more characters available (for rapid input detection)
|
|
181
|
+
has_more_input = IO.select([$stdin], nil, nil, 0)
|
|
182
|
+
|
|
183
|
+
# If this is rapid input or there are more characters available
|
|
184
|
+
if is_rapid_input || has_more_input
|
|
185
|
+
buffer = char.to_s.dup
|
|
186
|
+
|
|
187
|
+
# Keep reading available characters
|
|
188
|
+
loop_count = 0
|
|
189
|
+
empty_checks = 0
|
|
190
|
+
|
|
191
|
+
loop do
|
|
192
|
+
# Check if there's data available immediately
|
|
193
|
+
has_data = IO.select([$stdin], nil, nil, 0)
|
|
194
|
+
|
|
195
|
+
if has_data
|
|
196
|
+
next_char = $stdin.getc rescue nil
|
|
197
|
+
break unless next_char
|
|
198
|
+
|
|
199
|
+
next_char = safe_to_utf8(next_char)
|
|
200
|
+
buffer << next_char
|
|
201
|
+
loop_count += 1
|
|
202
|
+
empty_checks = 0 # Reset empty check counter
|
|
203
|
+
else
|
|
204
|
+
# No immediate data, but wait a bit to see if more is coming
|
|
205
|
+
# This handles the case where paste data arrives in chunks
|
|
206
|
+
empty_checks += 1
|
|
207
|
+
if empty_checks == 1
|
|
208
|
+
# First empty check - wait 10ms for more data
|
|
209
|
+
sleep 0.01
|
|
210
|
+
else
|
|
211
|
+
# Second empty check - really no more data
|
|
212
|
+
break
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
218
|
+
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
219
|
+
# Ensure the accumulated buffer is valid UTF-8 before regex operations
|
|
220
|
+
buffer = safe_to_utf8(buffer)
|
|
221
|
+
# Remove any trailing \r or \n from rapid input buffer
|
|
222
|
+
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
223
|
+
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Single character, continue to normal handling
|
|
227
|
+
char = buffer[0] if buffer.length == 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Handle control characters
|
|
231
|
+
case char
|
|
232
|
+
when "\r" then :enter
|
|
233
|
+
when "\n" then :newline # Shift+Enter sends \n
|
|
234
|
+
when "\u007F", "\b" then :backspace
|
|
235
|
+
when "\u0001" then :ctrl_a
|
|
236
|
+
when "\u0002" then :ctrl_b
|
|
237
|
+
when "\u0003" then :ctrl_c
|
|
238
|
+
when "\u0004" then :ctrl_d
|
|
239
|
+
when "\u0005" then :ctrl_e
|
|
240
|
+
when "\u0006" then :ctrl_f
|
|
241
|
+
when "\u000B" then :ctrl_k
|
|
242
|
+
when "\u000C" then :ctrl_l
|
|
243
|
+
when "\u000F" then :ctrl_o
|
|
244
|
+
when "\u0012" then :ctrl_r
|
|
245
|
+
when "\u0015" then :ctrl_u
|
|
246
|
+
when "\u0016" then :ctrl_v
|
|
247
|
+
when "\u0017" then :ctrl_w
|
|
248
|
+
when "\t" then :tab
|
|
249
|
+
else char
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Flush output
|
|
254
|
+
def flush
|
|
255
|
+
$stdout.flush
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Ensure a string is valid UTF-8.
|
|
260
|
+
# stdin stays in UTF-8 mode so getc returns complete characters (including CJK).
|
|
261
|
+
# This method handles the rare case where an invalid byte slips through
|
|
262
|
+
# (e.g. a stray terminal escape or a partial sequence) by scrubbing it out
|
|
263
|
+
# rather than letting ArgumentError crash the input loop.
|
|
264
|
+
# @param str [String] String from getc (UTF-8 encoded, but may have invalid bytes)
|
|
265
|
+
# @return [String] Valid UTF-8 string
|
|
266
|
+
private def safe_to_utf8(str)
|
|
267
|
+
return str if str.valid_encoding?
|
|
268
|
+
|
|
269
|
+
Octo::Utils::Encoding.sanitize_utf8(str)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|