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